/*
  The feature hub -- shared by index.html (venues) and app.html (visitors).

  WHY THIS IS A FILE AND NOT COPIED INTO BOTH. Every other page on this site
  carries its own copy of the CSS it needs, and that convention has already
  cost us once: the legal pages sat on the pre-rebrand violet for months
  because nobody remembered the second copy. The hub is the most intricate
  thing on the site -- tuned spoke geometry, eight absolutely-positioned
  cards, a 3D scene -- and it is now on two pages. A third copy of tuned
  geometry is not a maintenance risk, it is a guarantee of drift.

  It needs no build step: both pages just <link> it.

  The COPY is not here. Each page supplies its own node titles and bodies
  through its own data-i18n dictionary, because the same eight features are
  described to a venue and to a visitor in different voices.

  THE TYPEFACES LIVE HERE TOO, for the same reason and after the same kind of
  drift. They were declared in index.html's inline <style> only, so app.html
  asked for "Fraunces" and "Nunito" by name, loaded neither, and rendered the
  whole visitor page in Georgia and the system sans -- two pages of one
  product in two typefaces. This file is the only stylesheet both pages link,
  so it is the only place a shared face can be declared once.
*/

/* Self-hosted (website/fonts/) rather than Google-served: no runtime request
   to a third party, which is both faster and the GDPR-clean option. Variable
   files, one per family.
   THE PAIRING IS THE BRIEF: Fraunces is a soft serif with museum weight and
   genuinely playful curves -- the type a cultural venue hangs on a wall;
   Nunito is the rounded sans a park wayfinding sign uses. Professional but
   playful, and neither is the geometric startup-sans that reads as
   machine-made. */
/* THE ICON, sized and coloured exactly as <ion-icon> was (see
   scripts/inline-icons.js, which replaced the unpkg CDN with an inlined
   sprite). 1em square and currentColor is the whole compatibility story: a
   dozen rules across both pages size these with `font-size` and colour them
   with `color`, and every one of them still means what it meant. */
.ion-icon {
  width: 1em;
  height: 1em;
  display: inline-block;
  vertical-align: middle;
  flex: none;
}

@font-face {
  font-family: "Fraunces";
  src: url("fonts/fraunces-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Nunito";
  src: url("fonts/nunito-var.woff2") format("woff2");
  font-weight: 200 1000;
  font-style: normal;
  font-display: swap;
}
/* Caveat, for ONE thing: the founder's name under the portrait and at the end of
   the story (verhaal/), where it stands in for a signature. A browser fetches a
   font only when a page uses it, so the other pages never download this file.
   Latin subset from Google Fonts, SIL Open Font License -- see fonts/LICENSE.txt. */
@font-face {
  font-family: "Caveat";
  src: url("fonts/caveat-latin.woff2") format("woff2");
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
/* ---- The hub -------------------------------------------------------
         Eight features around the app they belong to. The 1000x620 box is a
         shared coordinate space: the SVG spokes use it as a viewBox, and the
         nodes are positioned as percentages of the same box, so the two line
         up at any width without measuring anything in JS.

         The 3D is perspective on the container plus translateZ on the parts,
         not a fake drop-shadow -- so a lifted node genuinely sits in front of
         its own spoke, and the phone reads as the object everything else is
         attached to. Kept shallow on purpose: enough to give depth, not
         enough to distort the labels. ---- */
      /* The wheel's dots exist only on the carousel layout -- on the desktop
         diagram every card is already visible, so dots would count nothing. */
      .hub-dots { display: none; }
      .hub {
        position: relative;
        aspect-ratio: 1000 / 720;
        /* KLEIN, want dit is een aanloop en geen attractie. Alles hierbinnen
           is in procenten, dus de breedte terugbrengen verkleint het diagram
           evenredig -- geen tweede set afmetingen om gelijk te houden. */
        max-width: 820px;
        margin: 0 auto;
        perspective: 1150px;
        /* Het vluchtpunt volgde ooit de cursor; dat is weg (zie hub.js: geen
           parallax meer in de pagina). --ox/--oy worden door niets meer gezet
           en vallen dus altijd terug op 0 -- ze blijven staan omdat dit de ene
           plek is waar een scene-brede verschuiving zou moeten aangrijpen als
           we hem ooit binnen de laag willen. */
        perspective-origin: calc(50% + var(--ox, 0px)) calc(45% + var(--oy, 0px));
      }
      /* The side labels sit HERE, in normal flow above the diagram, not in
         the absolutely-positioned layer. That is what makes the overlap
         impossible rather than merely unlikely -- nothing in the hub shares
         a stacking context with them any more. */
      /* Centred and given some life -- it is the invitation to touch the
         diagram, and it was sitting flush left after its two sibling labels
         were removed and space-between had nothing left to space. */
      .hub-key {
        display: flex; justify-content: center; margin: 0 auto 18px;
      }
      /* NOT A BUTTON (2026-08-09, user-reported: "looks like it can be pressed
         too"). This was a pill with a border, a gradient fill and a pulsing
         dot -- the exact costume of a primary action -- worn by a <span> that
         does nothing, while the genuinely clickable things (the hub nodes)
         carried no such cue at all. Every affordance it had was a lie, and it
         out-shouted the real ones.
         It is an instruction now: no pill, no border, no fill. The copy does
         the work instead, and tells the visitor the diagram is interactive
         rather than describing what the section contains. The dot stays --
         on a caption it reads as "something is live here", which is true. */
      .hub-key-hint {
        display: inline-flex; align-items: center; gap: 9px;
        font-size: 14.5px; font-weight: 600; color: var(--accent);
        text-align: center;
      }

      /* THE AUDIENCE SWITCH (2026-08-10: moved out of the header, above the hero).
         In the top bar it was one control among four, sized like the language
         toggle -- legible, but nothing about it said "this site has two halves".
         Above the hero it is the FIRST thing the page says: which of the two
         people you are. Styled as a real switch -- glass track, gradient thumb
         on the side you are on -- because it navigates between the two halves
         of one product, not between pages of a menu. Defined once here, used
         by both index.html and app.html. */
      /* Block wrapper so the switch owns its LINE -- as a bare inline-flex it
         shared the text line with the inline-block eyebrow next to it, and the
         pair centred together, shoving the switch off-centre. The wrapper
         inherits each page's own alignment: centred hero on index, left on
         app.html. */
      /* THE AUDIENCE BAR (2026-08-12). Fourth iteration, and the previous three
         each failed in a way worth recording: a segmented PILL read as a
         setting; bordered CARDS read as widgets sitting on the page; and
         edge-anchored zones with their own line art competed with the banner
         they were standing in -- and, sitting over the banner's middle, broke
         up the artwork exactly where it is deliberately sparse.
         So the bar now owns its own strip between the topbar and the banner
         and touches neither. It is the page split in two: left half venues,
         right half visitors, divided by a seam faint enough to read as an
         implied line rather than a drawn border.
         The whole half is the target -- no pill, no icon, no outline. The only
         cues are that the half you are on is a little lighter and carries a
         hue, and the half you are not on carries a prompt telling you it can
         be clicked. That prompt is what makes it understandable first time,
         which is the thing a purely tonal difference could not do on its own. */
      /* NO BOTTOM RULE. A hard line here cut the bar off from the banner and
         made it a separate widget again -- the exact thing four iterations
         have been trying to stop. The lit half's own gradient simply runs out
         before the bar ends, so the strip DISSOLVES into the artwork below it
         instead of being fenced off from it. */
      /* NO SEAM. Even one faint pixel down the middle read as a border, which
         made the two halves look like cells in a control again. The split is
         carried by the LIGHT instead: each half's wash is anchored to its own
         OUTER edge and fades to nothing before the centre, so the two meet in
         a gap rather than at a line. Nothing is drawn between them at all. */
      .audience-bar {
        position: relative;
        display: grid; grid-template-columns: 1fr 1fr;
      }
      .ab-half {
        position: relative;
        display: flex; flex-direction: column; gap: 2px;
        min-height: 44px; padding: 11px 26px 13px; justify-content: center;
        /* flex-start, NOT the default stretch. A flex item is blockified, so
           .ab-label's inline-block was ignored and the label filled the whole
           half -- which meant its hover underline (drawn left:0/right:0 on the
           label) ran from the text all the way to the centre of the page. The
           right half only ever looked correct because it sets flex-end below.
           Both sides now size the label to its own text. */
        align-items: flex-start;
        text-decoration: none;
        transition: background 320ms ease;
      }
      .ab-right { align-items: flex-end; text-align: right; }


      .ab-label {
        position: relative; display: inline-block;
        font-size: 13px; font-weight: 700; letter-spacing: 0.05em;
        text-transform: uppercase; color: var(--ink-soft);
        transition: color 320ms ease, text-shadow 320ms ease;
      }
      /* An underline that GROWS from the outer edge on hover, rather than a
         colour swap alone -- the type had no response of its own and read as
         flat. Drawn on the label, not the half, so it tracks the words. */
      .ab-label::after {
        content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1.5px;
        background: linear-gradient(90deg, var(--accent), var(--accent2));
        transform: scaleX(0); transform-origin: left center;
        transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
      }
      .ab-right .ab-label::after { transform-origin: right center; }

      /* The prompt only ever appears on the side you are NOT on -- on your own
         side it would be telling you to go where you already are. */
      .ab-hint {
        font-size: 11.5px; letter-spacing: 0.01em; color: var(--ink-soft);
        opacity: 0.6; transition: opacity 320ms ease, color 320ms ease;
      }
      /* The arrow is part of the prompt, not the markup -- one pseudo-element
         beats the same span repeated across four dictionaries. It slides on
         hover, which is the cue that says "this goes somewhere". */
      .ab-hint::after {
        content: "\2192"; display: inline-block; margin-left: 5px;
        transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
      }
      /* IN FRONT, NOT BEHIND. A back-arrow trailing its own label reads as a
         stray dash ("Klik voor de locatiepagina-"), which is exactly what it
         looked like on the page. ::after cannot lead the text, so the left
         variant suppresses it and draws a ::before instead. */
      .ab-left .ab-hint::after { content: none; }
      .ab-left .ab-hint::before {
        content: "\2190"; display: inline-block; margin-right: 5px;
        transition: transform 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
      }
      .ab-half.is-current .ab-hint { display: none; }

      /* THE LIT HALF. More hue and a real glow than the first pass: a radial
         bloom anchored to the TOP edge so the light appears to fall from the
         header and fade out downward, plus a soft halo on the label itself.
         Still no border, no fill boundary -- light is the whole marker. */
      /* Anchored to the OUTER edge of whichever half it is on -- 0% for the
         left, 100% for the right -- so the light is brightest where the label
         sits and has gone entirely by the centre. That fade is what replaced
         the seam: the division is where the two washes run out. */
      /* DE HELFT WAAR JE AL BENT IS DE RUSTIGE. Hier lag het omgekeerd: deze
         kant had de accentkleur, een gloed en een wash, en de kant waar je
         heen kon was grijs op bijna wit. Iemand aankondigen waar hij al staat
         kost precies de aandacht die de bestemming nodig heeft. */
      .ab-half.is-current { cursor: default; }
      .ab-half.is-current .ab-label { color: var(--ink-muted); font-weight: 600; }

      /* DE BESTEMMING IS EEN ZICHTBAAR ELEMENT. Geen naad, geen rand tussen de
         helften -- daar wilden vier eerdere passes vanaf en dat blijft zo. Wel
         een vorm OM de bestemming heen, zodat er iets is om aan te klikken in
         plaats van twee regels grijze tekst. */
      .ab-half:not(.is-current) {
        border-radius: 12px;
        background: #ffffff;
        box-shadow: 0 1px 3px rgba(11, 16, 21, 0.06);
        margin: 6px 0;
      }
      /* De kaart moet om de TEKST vallen, niet om de halve pagina: als
         grid-item vulde hij zijn hele cel, en dat waren 720 pixels wit voor
         twee regels. */
      .ab-right:not(.is-current) { justify-self: end; margin-right: 24px; }
      .ab-left:not(.is-current) { justify-self: start; margin-left: 24px; }
      .ab-half:not(.is-current) .ab-label { color: var(--ink); }
      /* De aanwijzing stond op 60% en verscheen pas op hover; op een aanraak-
         scherm bestaat hover niet, dus daar was hij er nooit helemaal. */
      .ab-half:not(.is-current) .ab-hint { opacity: 1; color: var(--accent-strong); font-weight: 600; }

      /* De hover-wash was wit op bijna wit en deed sinds de nieuwe drager
         niets meer. Een lichte lift zegt hetzelfde en werkt op elke tint. */
      .ab-half:not(.is-current):hover,
      .ab-half:not(.is-current):focus-visible {
        box-shadow: 0 4px 12px rgba(11, 16, 21, 0.1);
      }
      .ab-half:not(.is-current):hover .ab-label,
      .ab-half:not(.is-current):focus-visible .ab-label { color: var(--ink); }
      .ab-half:not(.is-current):hover .ab-label::after,
      .ab-half:not(.is-current):focus-visible .ab-label::after { transform: scaleX(1); }
      .ab-half:not(.is-current):hover .ab-hint,
      .ab-half:not(.is-current):focus-visible .ab-hint { opacity: 1; color: var(--accent); }
      .ab-right:hover .ab-hint::after, .ab-right:focus-visible .ab-hint::after { transform: translateX(4px); }
      .ab-left:hover .ab-hint::before, .ab-left:focus-visible .ab-hint::before { transform: translateX(-4px); }
      .ab-half:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

      @media (prefers-reduced-motion: reduce) {
        .ab-half, .ab-label, .ab-label::after, .ab-hint, .ab-hint::after, .ab-hint::before { transition: none; }
      }
      @media (max-width: 560px) {
        .ab-half { padding: 9px 16px 11px; }
        .ab-label { font-size: 12px; }
        .ab-hint { font-size: 10.5px; }
      }
      /* NARROW HEADERS. Brand + language + Login share one row (the audience
         switch lives above the hero since 2026-08-10), and on a phone the
         Login button was being pushed off the right edge entirely. */
      @media (max-width: 600px) {
        /* index.html's language control, and app.html's -- they are different
           elements with the same job, so both are named here. */
        .lang-btn { padding: 3px 5px; font-size: 12px; }
        .lang button { padding: 4px 8px; font-size: 12px; min-height: 32px; }

        /* THE WORDMARK STAYS. An earlier pass clipped the logo to its mark to
           buy room; that was wrong -- "Tixit" beside the mark IS the brand,
           not decoration. The room comes from the two switches instead, which
           are navigation and can afford to be terse.
           Measured, because trimming padding by eye did not come close: at
           375px this header wants ~397px of content in 335px with both
           switches full. The two rules below take it to ~332px. */
        .brand { flex: none; }
        .brand svg { flex: none; width: auto; }

        /* EACH SWITCH SHOWS ONE SIDE: the one you are NOT on, which is the
           only one worth pressing. The audience pair drops from ~146px to
           ~81px and the language pair from 50px to 23px.
           Both stay correct on their own -- press it and it becomes the
           current side, so it hides, and the other takes its place. No script
           change anywhere; every button is still in the DOM and still wired.
           What is given up is the "you are here" half of the control. On a
           phone that is the cheapest thing in the header to lose: the page
           under it already says which side you are on, and the desktop layout
           (above 600px) keeps both sides visible. */
        .lang-btn.active,
        .lang button.active { display: none; }
        /* ...and with one side gone the divider between them is a stray pipe
           hanging off the end of "EN". It read as a clipped control on both
           pages. */
        .lang-sep { display: none; }

        /* LOGIN IS A LINK ON A PHONE, not a button (2026-08-15). It became
           the SECONDARY button when the signup took over as primary, and a
           1px border was all it took to push this row past a 390px viewport
           -- which a phone answers by zooming the whole page out. Trimming
           padding only bought a few pixels back; dropping the box removes the
           constraint, and a text link is what a phone header conventionally
           carries for an account anyway.
           This lives here rather than in index.html because the rest of the
           narrow-header budget above does, and two files tuning one row is
           how the measurements in this block stop being true. */
        #loginBtnHeader {
          background: none; border: none; box-shadow: none;
          padding: 6px 0; font-size: 13px; color: var(--ink);
        }
        #loginBtnHeader:hover { background: none; filter: none; color: var(--accent); }
      }
      /* One more notch for the genuinely narrow ones (320px, an old SE). The
         375px budget clears with only a few px to spare once the wordmark is
         kept, and 320 does not have them. The brand comes down in SIZE here
         rather than losing the wordmark -- a smaller lockup is still the
         lockup; a cropped one is not. */
      @media (max-width: 380px) {
        .header-inner, .head-inner { gap: 6px; padding-left: 12px; padding-right: 12px; }
        .brand svg { height: 26px; }
        #loginBtnHeader { font-size: 12px; }
      }
      /* And the last stop: a 320px screen has 280px of usable width, which the
         380px settings still miss by about ten. Everything shrinks a shade
         further; the wordmark survives all of it, which is the point. */
      @media (max-width: 340px) {
        .brand svg { height: 24px; }
        .lang-btn, .lang button { padding: 3px 4px; font-size: 11.5px; }
        #loginBtnHeader { font-size: 11.5px; }
        .header-inner, .head-inner { gap: 5px; padding-left: 10px; padding-right: 10px; }
      }
      .hub-key-hint::before {
        content: ""; width: 9px; height: 9px; border-radius: 50%;
        background: var(--accent); flex: none;
        box-shadow: 0 0 0 0 rgba(46,211,183,0.6);
        animation: hintPulse 2.4s ease-out infinite;
      }
@media (prefers-reduced-motion: reduce) {
        .hub-key-hint::before { animation: none; }
}

      .hub-key-side {
        font-size: 11px; font-weight: 700; letter-spacing: 0.14em;
        text-transform: uppercase; color: var(--ink-muted); white-space: nowrap;
      }

      .hub-scr { height: 100%; display: flex; flex-direction: column; }

      .scr-steps {
        position: absolute; left: 6%; right: 6%; bottom: 13%;
        z-index: 2; pointer-events: none;
        display: grid;
      }
      .scr-steps span {
        grid-area: 1 / 1;
        background: rgba(6, 9, 12, 0.82);
        border: 1px solid var(--border);
        border-radius: 8px;
        padding: 5px 8px;
        font-size: clamp(8px, 0.95vw, 11px);
        font-weight: 650; line-height: 1.3;
        /* A NEAR-BLACK PILL OWNS ITS OWN FOREGROUND. This sat on var(--ink),
           which is the PAGE's ink and therefore dark -- dark text on an 82%
           black fill, invisible on every screen size. The phone's text tokens
           are aliased to the page's below (.hub-screen), which is right for
           the light phone body and wrong for anything painting a dark fill
           over it. */
        color: #f4fbfb;
        text-align: center;
        opacity: 0;
      }
      .hub-scr.playing .scr-steps span {
        animation: stepShow 1400ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
      }
      .hub-scr.playing .scr-steps span:nth-child(2) { animation-delay: 1300ms; }
      .hub-scr.playing .scr-steps span:nth-child(3) {
        animation-name: stepStay; animation-delay: 2600ms;
      }
      .hub-scr[data-scr="7"] .m-nora { opacity: 0; }
      .hub-scr.playing[data-scr="7"] .m-nora {
        animation: friendJoin 700ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1350ms;
        transform-box: fill-box;
        transform-origin: center;
      }
      .hub-scr.playing[data-scr="7"] .m-me {
        animation: driftA 2600ms ease-in-out both;
        animation-delay: 2700ms;
      }
      .hub-scr.playing[data-scr="7"] .m-finn {
        animation: driftB 2600ms ease-in-out both;
        animation-delay: 2900ms;
      }
      .hub-lines { position: absolute; inset: 0; width: 100%; height: 100%; }
      .spoke {
        stroke: var(--border-strong);
        stroke-width: 1.5;
        vector-effect: non-scaling-stroke;
        transition: stroke 220ms ease-out, stroke-width 220ms ease-out;
      }
      .spoke.lit { stroke: var(--accent); stroke-width: 2.5; }
      /* The phone. transform-style so its own children keep depth. */
      /* A specular sweep across the glass. Screens are shiny; a perfectly
         matte one is the single biggest tell that a mockup is a rectangle
         with a picture in it. Sits above the screen and never takes a
         pointer event. */
      /* THE HIGHLIGHT MOVES, THE SURFACE DOES NOT (2026-08-09, user-reported:
         "the reflection moves out of the phone"). This used to translateX the
         whole pseudo-element by up to 46% of its own width, so the BOX slid
         off the phone and the highlight went with it -- a bright band floating
         beside the device, which is exactly what a reflection cannot do.
         Adding overflow:hidden to the phone is not the fix either: it kills
         the preserve-3d its children depend on.
         Instead the box is pinned at inset 0 forever and only the BACKGROUND
         slides across it. Painted backgrounds are clipped to the border box
         (and to border-radius), so the highlight is now physically incapable
         of leaving the glass however far the cursor goes. */
      .hub-phone::after {
        content: "";
        position: absolute; inset: 0;
        border-radius: inherit;
        pointer-events: none;
        background-image: linear-gradient(
          115deg,
          rgba(255, 255, 255, 0) 34%,
          rgba(255, 255, 255, 0.11) 46%,
          rgba(255, 255, 255, 0.03) 54%,
          rgba(255, 255, 255, 0) 66%
        );
        /* Wider than the phone so there is gradient to spare at either end --
           at 100% the band would run out and the glass would go flat at the
           extremes. */
        background-size: 260% 100%;
        background-repeat: no-repeat;
        background-position: var(--sheen, 50%) center;
        transition: background-position 420ms cubic-bezier(0.16, 0.84, 0.44, 1);
        z-index: 4;
      }
      .hub-phone {
        position: absolute; left: 50%; top: 50%;
        width: 23.2%; aspect-ratio: 232 / 470;
        /* Leans toward whichever side is active -- a left feature turns the
           phone's face left, a right feature turns it right. The tilt is the
           depth cue doing real work: it says which spoke you just followed.
           Kept to a few degrees; past about 12 the screen starts to distort
           and stops reading as a phone. */
        /* TWEE ROTATIES, BIJ ELKAAR OPGETELD (2026-08-09). --lean/--tilt zijn
           de lean hierboven; --px-lean/--px-tilt waren de cursor. Die laatste
           worden sinds 2026-08-29 door niets meer gezet -- de parallax is weg
           -- en vallen terug op 0. Ze staan er nog omdat de optelling de juiste
           vorm is: wie hier ooit een tweede rotatie bij wil, telt hem op in
           plaats van de lean te vervangen, want die zegt welke spaak je volgde. */
        transform: translate(-50%, -50%)
          rotateY(calc(var(--lean, -7deg) + var(--px-lean, 0deg)))
          rotateX(calc(var(--tilt, 3deg) + var(--px-tilt, 0deg)))
          translateZ(60px) translateX(var(--shift, 0))
          translateY(var(--lift, 0));
        transform-style: preserve-3d;
        /* THICKNESS. A real handset is about 8mm on a 71mm body -- near enough
           12% -- and the phone here renders around 280px wide at full width,
           so ~34px is life-size. The first pass used a flat 15px, which was
           less than half that and read as a sheet of glass (user: "still very
           small, a real phone is far more thick").
           It has to scale with the phone or the proportion only holds at one
           viewport: the phone is 23.2% of a hub that stops growing at .wrap's
           1240px ceiling, so vw tracks it until then and the clamp holds it
           afterwards. Deliberately at the generous end of realistic -- a
           shallow rotation and 1150px of perspective both foreshorten the
           side, so a literally-correct depth still under-reads. */
        --depth: clamp(22px, 2.8vw, 36px);
        /* NO `filter` HERE -- THIS IS LOAD-BEARING (2026-08-09). It used to
           carry `filter: drop-shadow(...)`, and `filter` is a grouping
           property: it forces the used value of transform-style to FLAT. So
           preserve-3d above was being silently overridden and every child of
           the phone was squashed into one plane. That is why the phone read as
           "a flat moving screen" however much it was rotated -- the side walls
           and back plate below would have been invisible too.
           The float shadow moved into box-shadow, which is not a grouping
           property and leaves 3D intact. Do not reintroduce filter, backdrop-
           filter, opacity < 1, or overflow: hidden on this element. */
        transition: transform 420ms cubic-bezier(0.16, 0.84, 0.44, 1);
        border-radius: 18px;
        padding: 6px;
        background: linear-gradient(150deg, #2a3138, #12171c);
        /* Carries the float shadow now that filter is gone -- the second layer
           is the one the old drop-shadow was doing. box-shadow does not
           flatten 3D; filter does. */
        box-shadow:
          0 30px 60px rgba(0, 0, 0, 0.5),
          0 26px 40px rgba(0, 0, 0, 0.42),
          0 0 0 1px var(--border);
      }

      /* ---- The phone as a solid, not a plane -------------------------------
         Two side walls and a back plate, each a real face of a box. The walls
         are swung back about their own outer edge, so they extend AWAY from
         the viewer and meet the back plate a --depth behind the screen.
         Signs matter here and are easy to get backwards: for a point at
         (x,0,0), rotateY(t) sends z to -x*sin(t). The right wall's content
         lies at negative x from its origin, so it needs rotateY(-90deg) to
         travel to negative z; the left wall's lies at positive x, so it needs
         +90deg. Swap either and the wall sticks out of the screen instead. */
      .phone-side {
        /* A FIXED inset, not a percentage. At 3.5% of a ~570px phone the wall
           started 20px below the front face and the seam visibly failed to
           line up with the top of the screen. This is tied to the 18px corner
           radius instead: the wall now starts where the front face reaches
           full width, so the two edges meet. */
        position: absolute; top: 10px; bottom: 10px;
        width: var(--depth);
        /* A METAL EDGE, not a fade to black. The old gradient ended on
           #0f1319 -- near enough the page background -- so the bottom half of
           the wall simply disappeared and the phone looked like it only had
           depth at the top. A real chamfered edge catches light at BOTH ends
           and stays mid-tone between them; nothing here goes darker than the
           body, so the silhouette holds along its whole length.
           THE ENDS FADE TO TRANSPARENT, and that is load-bearing. This wall is
           a straight rectangle while the front face is rounded at 18px, so
           near the corners the two cannot agree -- a hard-capped wall pokes
           past the curve and shows as a bright sliver that flickers as the
           phone turns (user: "weird white line sometimes glitches", "depth
           lines in some angles are off"). Fading the last few percent means
           there is no hard end to catch the light, and the mismatch has
           nothing to draw with. Done in the BACKGROUND rather than with a
           mask: mask/filter/opacity are grouping properties and would flatten
           this element's preserve-3d, taking the keys' translateZ with it. */
        background: linear-gradient(
          180deg,
          rgba(92, 103, 115, 0) 0%,
          #5c6773 4%,
          #333b44 16%,
          #2a313a 46%,
          #363f49 78%,
          #4d5762 96%,
          rgba(77, 87, 98, 0) 100%
        );
        transform-style: preserve-3d;
        pointer-events: none;
        z-index: 1;
      }
      .phone-side-r { right: 0; transform-origin: 100% 50%; transform: rotateY(-90deg); }
      .phone-side-l { left: 0;  transform-origin: 0 50%;    transform: rotateY(90deg); }

      /* Keys sit ON their wall and stand slightly proud of it. Because they
         are children of an already-rotated wall, translateZ pushes them
         straight out of the phone's side, which is exactly where a button
         goes -- no per-side maths needed. */
      /* The keys were invisible for two reasons, both fixed here: they were
         within a shade of the wall they sit on, and they stood 1.5px proud of
         it, which is nothing at this size. They are lighter than the wall now
         (a button is a separate piece of metal, and catches light differently
         from the frame), they stand 3px out, and they run slightly WIDER than
         the wall so they break the phone's outline the way a real key does --
         a bump on the silhouette is what you actually notice, more than the
         face of the button itself. */
      .phone-key {
        position: absolute;
        left: -1.5px; right: -1.5px;
        border-radius: 2px;
        background: linear-gradient(180deg, #8b97a5 0%, #5a6572 45%, #414b56 100%);
        transform: translateZ(3px);
        /* Whole pixels only. The previous 0.5px pair (a dark outline and a
           white inset top edge) sat below device-pixel resolution at most zoom
           levels, and a sub-pixel white line is exactly the kind of thing that
           snaps in and out as the phone rotates -- the other half of the
           flicker. The highlight is carried by the gradient above instead. */
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
      }
      /* Where they sit on a real handset: power alone on the right, volume as
         a pair slightly higher on the left.
         WHICH ONE YOU ACTUALLY SEE: the phone leans -7deg at rest and travels
         about -16deg to +2deg with the pointer, and across nearly all of that
         it is the LEFT wall that faces the viewer -- so the volume pair is the
         visible one and the power key is hidden almost always. That is correct
         and realistic (you see one side of a phone at a time); don't go
         hunting for a bug because the power key never appears. Two keys on the
         visible side is also the better outcome -- a pair reads as a phone
         faster than a single bump does. */
      .phone-key-power { top: 30%; height: 11%; }
      .phone-key-volup { top: 21%; height: 7.5%; }
      .phone-key-voldn { top: 31%; height: 7.5%; }

      /* The back. Also stops you seeing through the body when it turns. */
      .phone-back {
        position: absolute; inset: 0;
        border-radius: inherit;
        background: linear-gradient(150deg, #262d35, #0c1015);
        transform: translateZ(calc(var(--depth) * -1));
        box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
        pointer-events: none;
      }

      /* The screen is the front face and must stay in front of the body. */
      .hub-phone .hub-screen { position: relative; z-index: 2; }
      /* THE SHEEN AND THE PHONE SHARE THIS DURATION AND EASING ON PURPOSE
         (user: "the phone needs to move in sync, otherwise it doesn't look
         like a reflection"). A highlight that settles on a different clock
         from the surface carrying it reads as a separate object sliding
         about. If you retune one, retune both -- they are one movement. */
      .hub-phone,
      .hub-phone::after {
        transition-duration: 260ms;
        transition-timing-function: cubic-bezier(0.16, 0.84, 0.44, 1);
      }
      /* Nothing here should move at all for someone who asked for less. */
      @media (prefers-reduced-motion: reduce) {
        .hub-phone, .hub-phone::after { transition: none; }
      }

      /* ---- NAAR BINNEN GETROKKEN -----------------------------------------
         Een laag OVER de pagina, niet een uitklap IN de pagina. Dat verschil
         is de hele reden dat dit zo gebouwd is: uitvouwen op de plek duwt
         alles eronder weg, en dan is de alinea die iemand aan het lezen was
         verschoven zodra hij terugkomt. Een laag laat de pagina staan.

         Er is één toestel. Bij het openen wordt het VERPLAATST naar deze laag
         en bij het sluiten terug -- geen tweede exemplaar met een tweede set
         schermen die uit elkaar kan lopen. */
      .hub-stage {
        position: fixed; inset: 0; z-index: 200;
        display: none;
        align-items: center; justify-content: center;
        padding: clamp(16px, 4vw, 44px);
        /* Doorzichtig genoeg om de site eronder te blijven zien. Op 94% bleef
           er van de pagina niets over en las de laag als een andere pagina. */
        background: rgba(11, 16, 21, 0.32);
        backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
      }
      .hub-stage.on { display: flex; }
      /* Een BEGRENSD paneel: het ligt op de pagina, het vervangt hem niet. */
      .hub-stage-panel {
        position: relative;
        display: flex; align-items: center;
        gap: clamp(20px, 4vw, 48px);
        width: min(900px, 100%); max-height: 100%;
        background: var(--bg-top);
        border: 1px solid var(--border);
        border-radius: 24px;
        padding: clamp(22px, 3vw, 38px);
        box-shadow: 0 24px 60px rgba(11, 16, 21, 0.24);
      }
      .hub-stage-phone {
        position: relative;
        width: min(300px, 74vw);
        aspect-ratio: 232 / 470;
        flex: 0 0 auto;
        perspective: 1400px;
      }
      /* In de laag staat het toestel recht voor je: de lean zei welke spaak
         je volgde, en die vraag is beantwoord zodra je binnen bent. Schuin
         zou nu alleen maar slechter leesbaar zijn. */
      .hub-stage-phone .hub-phone {
        position: absolute; left: 50%; top: 50%;
        right: auto; bottom: auto;
        transform: translate(-50%, -50%);
        width: 100%;
        --lean: 0deg; --tilt: 0deg; --px-lean: 0deg; --px-tilt: 0deg;
        --shift: 0;
        cursor: default;
      }
      .hub-stage-side { flex: 0 1 340px; max-width: 340px; }
      .hub-stage-title {
        font-family: Fraunces, Georgia, serif; font-weight: 900;
        font-size: clamp(22px, 3vw, 30px); line-height: 1.12;
        margin: 0 0 10px; color: var(--ink);
      }
      .hub-stage-body {
        margin: 0 0 22px; font-size: 15px; line-height: 1.6;
        color: var(--ink-soft);
      }
      .hub-stage-rail { display: flex; flex-wrap: wrap; gap: 7px; }
      .hub-stage-rail button {
        border: 1.5px solid var(--border); background: none;
        border-radius: 12px; padding: 7px 13px; cursor: pointer;
        font: inherit; font-size: 13px; color: var(--ink-soft);
      }
      .hub-stage-rail button[aria-current="true"] {
        background: var(--ink); color: var(--paper); border-color: var(--ink);
      }
      .hub-stage-close {
        position: absolute; top: 14px; right: 14px;
        width: 44px; height: 44px; border-radius: 999px;
        border: 1.5px solid var(--ink); background: none;
        color: var(--ink); font-size: 21px; line-height: 1; cursor: pointer;
      }
      /* Op een telefoon staan tekst en toestel onder elkaar in plaats van
         naast elkaar, en het toestel krimpt zodat de knoppenrij mee past. */
      @media (max-width: 820px) {
        .hub-stage { overflow-y: auto; align-items: flex-start; }
        /* Geen plafond op de hoogte: toestel plus tekst is hoger dan een
           telefoonscherm, en met max-height: 100% liep de inhoud uit het
           paneel. Het paneel groeit mee en de laag zelf scrolt. */
        .hub-stage-panel { flex-direction: column; max-height: none; align-items: stretch; }
        /* DE KNOPPEN EERST, EN IN BEELD (2026-09-13). De gebruiker: "the
           feature pills should be selectable in the view without scrolling".
           Ze stonden onder toestel en tekst, buiten het scherm. De zijkant
           wordt display: contents, zodat kop, tekst en knoppenrij losse
           kinderen van het paneel zijn en de rij naar voren kan: eerst de
           knoppen, dan een kleiner toestel, dan de kop en de tekst. Rechts
           blijft ruimte voor het sluitkruis. */
        .hub-stage-side { display: contents; }
        /* Het sluitkruis wordt een eigen korte eerste rij in plaats van een
           hoek die de knoppenrij 52px moest vrijlaten: met die marge paste er
           nog maar één knop per rij en stonden er acht onder elkaar, en dan
           stond het toestel alsnog onder de vouw. */
        .hub-stage-close {
          position: static; order: -2; align-self: flex-end;
          width: 36px; height: 36px; font-size: 18px; margin: -6px -6px 2px 0;
        }
        .hub-stage-rail { order: -1; padding-right: 0; gap: 7px; }
        .hub-stage-rail button { padding: 7px 11px; font-size: 12.5px; min-height: 36px; }
        .hub-stage-phone { width: min(156px, 40vw); align-self: center; margin: 8px 0 0; }
        .hub-stage-title { margin-top: 4px; }
        .hub-stage-body { margin-bottom: 0; }
      }
      /* De pagina eronder mag niet meescrollen zolang de laag open staat. */
      body.hub-locked { overflow: hidden; }

      /* ---- SECTIEPANELEN. Gedeeld door beide pagina's. Een sectie krijgt
         class="sec" en een eigen --sec, en staat dan als paneel op de drager
         in plaats van als band van rand tot rand. Het verschil zit in de lucht
         eromheen: van rand tot rand is een streep over de pagina, met marge is
         het een blok dat op zichzelf staat. ---- */
      .sec > .wrap {
        background: var(--sec, transparent);
        border-radius: 28px;
        padding-top: clamp(40px, 5vw, 68px);
        padding-bottom: clamp(40px, 5vw, 68px);
        padding-left: clamp(24px, 3.4vw, 46px);
        padding-right: clamp(24px, 3.4vw, 46px);
      }
      /* De secties raken elkaar niet: de drager hoort ertussendoor te lopen,
         anders zijn het alsnog banden die toevallig ronde hoeken hebben. */
      .sec { padding-top: 14px; padding-bottom: 14px; }
      @media (max-width: 620px) {
        .sec > .wrap { border-radius: 20px; }
      }

      /* ---- DE UITNODIGING -------------------------------------------------
         Het diagram staat stil, en stilstaande dingen vragen nergens om. Er
         moet dus iets bij dat zegt dat je erin kunt -- zacht genoeg om de
         alinea ernaast niet te storen, herkenbaar genoeg om te snappen.

         Het vier-hoeken-teken doet het werk: dat betekent overal hetzelfde,
         dus het hoeft niet uitgelegd te worden en er is geen zin voor nodig.
         Flauw als je alleen leest, vol zodra je met de muis in de buurt komt,
         en altijd vol voor wie met het toetsenbord navigeert -- die krijgt
         geen hover en zou het anders nooit zien. */
      .hub-open-mark {
        /* Vlak ONDER het toestel, gecentreerd: het hoort bij het ding dat je
           aanklikt, en het dekt het scherm niet af. Het toestel is 23,2% breed
           op 232/470, dus zijn onderrand ligt rond 83% van de hubhoogte. */
        position: absolute; left: 50%; top: 88%;
        transform: translate(-50%, -50%);
        width: 34px; height: 34px; border-radius: 999px;
        display: flex; align-items: center; justify-content: center;
        background: var(--ink); color: var(--paper);
        opacity: 0.34; pointer-events: none; z-index: 4;
        transition: opacity 200ms ease-out, transform 200ms ease-out;
      }
      .hub-open-mark svg { width: 17px; height: 17px; display: block; }
      .hub:hover .hub-open-mark,
      .hub:focus-within .hub-open-mark {
        opacity: 1;
        /* De verplaatsing MOET hier herhaald worden: transform vervangt,
           het stapelt niet. Zonder translate sprong het teken bij hover
           terug naar de hoek van zijn eigen doos. */
        transform: translate(-50%, -50%) scale(1.06);
      }
      .hub-phone { cursor: zoom-in; }
      .hub-phone { transition: transform 260ms cubic-bezier(0.16,0.84,0.44,1); }
      .hub:hover .hub-phone { --lift: -6px; }
      /* In de laag is het toestel geen knop meer en hoort het teken weg. */
      .hub-stage-phone .hub-phone { cursor: default; }
      /* GEEN WIEGEN IN DE LAAG (2026-09-13). Onder 1000px wiegt het toestel
         (phoneSway, hieronder) en een animatie-transform wint van elke gewone
         transform-regel -- dus de translate(-50%, -50%) hierboven deed in de
         laag niets en het toestel stond met zijn linkerbovenhoek in het midden
         van zijn vak: half buiten beeld, over de tekst. De gebruiker: "the
         popup screen is messy and partly out of the view". */
      .hub-stage-phone .hub-phone { animation: none; }
      .hub-stage .hub-open-mark { display: none; }
      @media (prefers-reduced-motion: reduce) {
        .hub-open-mark { transition: none; }
      }
      /* A plain frame. It used to centre one icon and a caption, and those
         rules reached into every icon and span of the real screens that
         replaced it -- which rendered them as a tiny strip halfway down. */
      .hub-screen {
        height: 100%; border-radius: 13px; overflow: hidden;
        background: linear-gradient(170deg, var(--bg-top), var(--bg-bottom));
      }
      /* The phone points AT the feature, on both axes. rotateY(+) turns its
         face toward +x, so a left feature needs a negative lean; rotateX(+)
         turns the face upward, so an upper feature needs a positive tilt.
         Together they aim it diagonally at whichever corner you picked. */
      .hub.side-l { --lean: -16deg; --shift: -1.5%; }
      .hub.side-r { --lean: 9deg; --shift: 1.5%; }
      .hub.row-up { --tilt: 11deg; }
      .hub.row-down { --tilt: -6deg; }
      /* Nodes. Buttons, not divs -- they are the primary way to read this
         section, so they have to be reachable and operable by keyboard. */
      .hub-node {
        position: absolute;
        width: 20.8%;
        transform: translate(var(--px, 0), var(--py, 0)) translateZ(var(--lift, 0))
                   rotateY(var(--face, 0deg));
        transform-style: preserve-3d;
        background: var(--glass);
        border: 1px solid var(--border);
        border-radius: 12px;
        /* Two layers: a tight contact shadow and a wide ambient one. A single
           shadow reads as a drawn edge; the pair reads as an object above a
           surface -- the same reasoning the app's ticket cards use. */
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 10px 26px rgba(0, 0, 0, 0.22);
        padding: 12px 13px;
        text-align: left;
        color: inherit;
        font: inherit;
        cursor: pointer;
        display: flex; flex-direction: column; gap: 6px;
        transition: transform 320ms cubic-bezier(0.16, 0.84, 0.44, 1),
                    background 200ms ease-out, border-color 200ms ease-out,
                    box-shadow 260ms ease-out;
      }
      /* Turned toward the phone, not square to the viewer. Both columns
         converge on the centre, which is what makes the arrangement read as
         a space with a middle rather than two lists at different depths. */
      .hub-node-l { left: var(--dx, 0); --face: 7deg; }
      .hub-node-r { right: var(--dx, 0); --face: -7deg; }
      .hub-node:hover, .hub-node:focus-visible {
        background: var(--glass-strong);
        border-color: var(--border-strong);
        --lift: 34px;
        /* Comes toward the viewer AND squares up to them -- a card you are
           reading should not still be turned away. */
        --face: 0deg;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.34), 0 22px 48px rgba(0, 0, 0, 0.4);
      }
      .hub-node[aria-expanded="true"] {
        /* Above its neighbours, so growing into the one below reads as a
           card lifted over them rather than a collision. */
        z-index: 5;
        background: var(--glass-strong);
        border-color: var(--accent);
        --lift: 52px;
        box-shadow: 0 22px 46px rgba(0, 0, 0, 0.45);
      }
      /* A pulsing dot in the corner of every closed card -- the same signal
         the hub's caption carries, so "this is pressable" is said in the one
         place people are actually looking. It takes the card's OWN icon
         colour, so the eight read as a set of distinct things rather than
         eight copies of one prompt.

         Goes when the card is open: it has done its job, and something still
         pulsing next to text you are reading is just noise. */
      .hub-node::before {
        content: "";
        position: absolute; top: 11px; right: 12px;
        width: 8px; height: 8px; border-radius: 50%;
        background: var(--fi-color, var(--accent));
        box-shadow: 0 0 0 0 var(--fi-color, var(--accent));
        animation: nodePulse 2.6s ease-out infinite;
        opacity: 0.9;
      }
      .hub-node[aria-expanded="true"]::before { display: none; }
@media (prefers-reduced-motion: reduce) {
        .hub-node::before { animation: none; }
}


      .hub-head { display: flex; align-items: center; gap: 9px; padding-right: 14px; }
      .hub-dot {
        width: 30px; height: 30px; border-radius: 9px; flex: none;
        display: grid; place-items: center;
        background: var(--fi-glow, rgba(46, 211, 183, 0.16));
        color: var(--fi-color, var(--accent));
        font-size: 17px;
      }
      .hub-title {
        font-size: clamp(12px, 1.15vw, 15px);
        font-weight: 700; line-height: 1.25; letter-spacing: -0.01em;
      }
      /* Closed, not absent: the text stays in the DOM so all eight features
         remain indexable, exactly as they were when this was a grid. */
      .hub-body {
        font-size: clamp(11px, 1vw, 13.5px);
        line-height: 1.5; color: var(--ink-soft);
        display: -webkit-box; -webkit-box-orient: vertical;
        overflow: hidden; -webkit-line-clamp: 0;
        max-height: 0; opacity: 0;
        transition: max-height 300ms cubic-bezier(0.16, 0.84, 0.44, 1), opacity 220ms ease-out;
      }
      .hub-node[aria-expanded="true"] .hub-body {
        -webkit-line-clamp: 8; max-height: 11em; opacity: 1;
      }
      .fi-ticket  { --fi-color: #2ed3b7; --fi-glow: rgba(46, 211, 183,  0.50); }
      .fi-pass    { --fi-color: #22b8e0; --fi-glow: rgba(34, 184, 224,  0.50); }
      .fi-id      { --fi-color: #818cf8; --fi-glow: rgba(129, 140, 248, 0.50); }
      .fi-reach   { --fi-color: #f472b6; --fi-glow: rgba(244, 114, 182, 0.50); }
      .fi-map     { --fi-color: #34d399; --fi-glow: rgba(52, 211, 153, 0.45); }
      .fi-alert   { --fi-color: #fbbf24; --fi-glow: rgba(251, 191, 36, 0.50); }

      /* ---- App-accurate screen vocabulary ---------------------------------
         Modelled on the real components rather than approximated: the active
         card is WalletScreen's activeCardInner (gradient "Vandaag" chip,
         calendar and pricetag info rows, the "Bekijken" pill), the ID grid is
         IdentityDocumentsCard's locked state, and screen 3 is the DASHBOARD's
         map editor, because that feature belongs to a venue, not a visitor.

         Sizes are in cqw where the phone allows it, so a screen reads the
         same at any hub width instead of breaking up when the column
         narrows. ---- */
      /* The screens were authored against a separate mockup's token names.
         Rather than rewrite 47 references, the names are aliased here to
         this page's real variables -- one place, and the markup keeps the
         app vocabulary it was written in. Without this the two maps drew
         themselves with an invalid fill and came out black. */
      .hub-screen {
        --tx: var(--ink);
        --se: var(--ink-soft);
        --mu: var(--ink-muted);
        --ac: var(--accent);
        --ru: var(--border);
        --sq: rgba(255, 255, 255, 0.10);
        --mapbg: #142029;
      }
      .hub-scr { container-type: inline-size; }
      .ap-top {
        display: flex; justify-content: space-between; align-items: center;
        padding: 6px 10px 2px; font-size: 5.5cqw; color: var(--mu); font-weight: 600;
      }
      .ap-top.dash { padding: 9px 10px; border-bottom: 1px solid var(--ru); }
      .ap-head {
        display: flex; align-items: center; justify-content: space-between;
        padding: 2px 10px 7px; color: var(--mu);
      }
      /* The real mark, not the word set in the page font. Sized off the
         phone's own width so it holds its proportion at any hub width. */
      .ap-logo { width: 13cqw; height: 5.5cqw; display: block; }
      .ap-brand { font-weight: 800; font-size: 7cqw; letter-spacing: -0.04em; color: var(--tx); }
      .ap-head .ion-icon { font-size: 6cqw; }
      .ap-dash-tag {
        font-size: 5cqw; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
        color: var(--ac);
      }
      /* position: relative, since 2026-09-12: the opened pass and the ID card
         lie OVER the list in this box (see .ap-passdetail). */
      .ap-body { position: relative; flex: 1; padding: 0 8px; display: flex; flex-direction: column; gap: 5px; overflow: hidden; }
      .ap-sec {
        font-size: 4.8cqw; font-weight: 700; letter-spacing: 0.11em;
        text-transform: uppercase; color: var(--mu); padding: 4px 2px 1px;
      }

      /* GlassCard: translucent fill, hairline border, radius 18 in the app. */
      .ap-card {
        display: flex; gap: 6px; align-items: center;
        background: rgba(255, 255, 255, 0.06);
        border: 1px solid rgba(255, 255, 255, 0.16);
        border-radius: 9px; padding: 6px 7px;
      }
      .lt .ap-card { background: rgba(11, 16, 21, 0.04); border-color: rgba(11, 16, 21, 0.14); }
      /* The active card is an opaque deep fill with a gradient edge, exactly
         as theme.ts's accentDeep + activeGradient do it. */
      .ap-active {
        /* SAME REASON AS .scr-steps ABOVE, and this is the one a visitor
           actually reads: the card paints an opaque deep gradient, while
           --tx/--se/--mu are aliased to the page's dark ink for the light
           phone body around it. Without these three the title, the holder
           line and both labels render dark on dark and the whole card reads
           as a black rectangle -- which is exactly how it shipped. */
        --tx: #f4fbfb;
        --se: rgba(244, 251, 251, 0.80);
        --mu: rgba(244, 251, 251, 0.64);
        color: var(--tx);
        align-items: flex-start;
        background: linear-gradient(150deg, #10403c, #0d2b33);
        border: 1.5px solid transparent;
        background-origin: border-box;
        background-clip: padding-box, border-box;
        background-image: linear-gradient(150deg, #10403c, #0d2b33),
                          linear-gradient(135deg, var(--ac), #16a8c7 55%, #22b8e0);
      }
      .ap-card-ico {
        width: 11cqw; height: 11cqw; border-radius: 6px; flex: none;
        display: grid; place-items: center; font-size: 6cqw;
        background: rgba(255, 255, 255, 0.11);
      }
      .ap-card-ico.sm { width: 8.5cqw; height: 8.5cqw; font-size: 4.6cqw; }
      .lt .ap-card-ico { background: rgba(11, 16, 21, 0.07); }
      .ap-card-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
      .ap-chip {
        align-self: flex-start;
        background: linear-gradient(135deg, var(--ac), #22b8e0);
        color: #06090c; font-size: 4cqw; font-weight: 800;
        letter-spacing: 0.05em; padding: 1.5px 6px; border-radius: 999px;
      }
      .ap-title { font-size: 6cqw; font-weight: 750; letter-spacing: -0.02em; }
      .ap-title.sm { font-size: 5.2cqw; font-weight: 700; }
      .ap-holder { font-size: 4.4cqw; color: var(--se); line-height: 1.3; }
      .ap-info { display: flex; align-items: center; gap: 5px; margin-top: 1px; }
      .ap-info-ico {
        width: 6.5cqw; height: 6.5cqw; border-radius: 5px; flex: none;
        display: grid; place-items: center; font-size: 4cqw;
        background: rgba(255, 255, 255, 0.12); color: var(--tx);
      }
      .ap-lbl { display: block; font-size: 3.6cqw; font-weight: 600; color: var(--mu); letter-spacing: 0.04em; }
      .ap-val { display: block; font-size: 4.4cqw; font-weight: 700; }
      .ap-pill {
        margin-left: auto; display: flex; align-items: center; gap: 2px;
        border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 999px;
        padding: 2px 7px; font-size: 4cqw; font-weight: 700; white-space: nowrap;
      }
      .ap-book {
        background: linear-gradient(135deg, var(--ac), #22b8e0); color: #06090c;
        font-size: 4cqw; font-weight: 800; padding: 3px 8px; border-radius: 999px; flex: none;
      }

      /* Push notification, then the opened message. */
      .ap-push {
        background: rgba(255, 255, 255, 0.13); border: 1px solid rgba(255, 255, 255, 0.22);
        border-radius: 9px; padding: 6px 8px; backdrop-filter: blur(8px);
      }
      .ap-push-head { display: flex; align-items: center; gap: 4px; font-size: 3.8cqw; color: var(--mu); font-weight: 700; }
      .ap-push-head .ion-icon { font-size: 4cqw; color: var(--ac); }
      .ap-push-now { margin-left: auto; }
      .ap-push-title { font-size: 5cqw; font-weight: 750; margin-top: 1px; }
      .ap-push-body { font-size: 4.3cqw; color: var(--se); line-height: 1.3; }
      .ap-msg {
        background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.16);
        border-radius: 9px; padding: 7px 8px; display: flex; flex-direction: column; gap: 5px;
      }
      .ap-msg-from { display: flex; align-items: center; gap: 6px; }
      .ap-msg-body { font-size: 4.4cqw; line-height: 1.4; color: var(--se); }
      .ap-msg-cta {
        align-self: flex-start; background: linear-gradient(135deg, var(--ac), #22b8e0);
        color: #06090c; font-size: 4.2cqw; font-weight: 800; padding: 4px 10px; border-radius: 999px;
      }

      /* Emergency: the real fixed alert, in danger colours, not the accent. */
      .ap-alert {
        background: rgba(248, 113, 113, 0.14); border: 1.5px solid #f87171;
        border-radius: 9px; padding: 7px 8px; display: flex; flex-direction: column; gap: 3px;
      }
      .ap-alert-title { font-size: 4.6cqw; font-weight: 800; color: #fecaca; letter-spacing: -0.01em; }
      .ap-alert-body { font-size: 4cqw; line-height: 1.35; color: var(--tx); }
      .ap-alert-from { font-size: 3.8cqw; color: var(--mu); }

      /* Passes and ID -- deliberately different pictures. */
      .ap-pass { align-items: center; }
      .ap-photo {
        width: 13cqw; height: 16cqw; border-radius: 5px; flex: none;
        background: rgba(255, 255, 255, 0.12); display: grid; place-items: center;
        font-size: 7cqw; color: var(--mu);
      }
      .ap-qr {
        width: 11cqw; height: 11cqw; flex: none; border-radius: 2px;
        background: repeating-linear-gradient(90deg, var(--tx) 0 1.5px, transparent 1.5px 3px),
                    repeating-linear-gradient(0deg, var(--tx) 0 1.5px, transparent 1.5px 3px);
        opacity: 0.85;
      }
      .ap-qr.big { width: 26cqw; height: 26cqw; }
      .ap-idgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
      .ap-id {
        border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 8px; overflow: hidden;
        background: rgba(255, 255, 255, 0.06);
      }
      .ap-id-blur {
        height: 17cqw; display: grid; place-items: center; font-size: 7cqw; color: var(--mu);
        background: repeating-linear-gradient(115deg, rgba(255,255,255,0.09) 0 6px, rgba(255,255,255,0.03) 6px 12px);
      }
      .ap-id-cap { font-size: 4cqw; font-weight: 700; padding: 3px 5px; color: var(--se); }
      .ap-faceid {
        display: flex; align-items: center; justify-content: center; gap: 5px;
        font-size: 4.2cqw; font-weight: 700; color: var(--ac);
        border: 1px dashed var(--ac); border-radius: 8px; padding: 6px;
      }
      .ap-faceid .ion-icon { font-size: 5cqw; }
      /* DE PAS EN DE ID-KAART, GETOOND (2026-09-12). Beide schermen lieten
         alleen een lijst zien: een pasrij met een icoontje, twee vervaagde
         tegels met een slot. De gebruiker: "it doesn't show the id card", en
         "it doesn't really show anything about subscriptions -- it should
         select a subscription with a mockup pass photo and some pass info".
         Dus: een tik op de rij, en dan de pas zelf -- pasfoto, houder, nummer,
         geldigheid, QR. En een tik op de tegel, Face ID, en dan de kaart zelf.
         De pasfoto is een abstract silhouet, hoofd en schouders in twee
         tinten: geen echte gezichten, ook geen getekende. */
      .ap-photo.lg { width: 21cqw; height: 26cqw; }
      .ap-face { position: relative; display: block; width: 70%; height: 74%; }
      .ap-face::before {
        content: ""; position: absolute; left: 50%; top: 4%; width: 46%; aspect-ratio: 1;
        border-radius: 50%; background: var(--mu); transform: translateX(-50%);
      }
      .ap-face::after {
        content: ""; position: absolute; left: 50%; bottom: 0; width: 100%; height: 44%;
        border-radius: 50% 50% 6% 6% / 100% 100% 6% 6%;
        background: var(--ac); transform: translateX(-50%);
      }
      /* De tik: een ring die uit het midden van de rij groeit en verdwijnt. */
      .ap-tapped { position: relative; }
      .ap-tap {
        position: absolute; left: 50%; top: 50%; width: 8cqw; height: 8cqw;
        margin: -4cqw 0 0 -4cqw; border-radius: 50%;
        border: 2px solid var(--ac); opacity: 0; pointer-events: none;
      }
      /* Het uitgeklapte: OVER de lijst, met de achtergrond van het scherm,
         zodat de lijst eronder verdwijnt in plaats van doorschijnt. */
      .ap-passdetail, .ap-idcard {
        position: absolute; left: 8px; right: 8px; top: 9cqw; opacity: 0;
        background: linear-gradient(170deg, var(--bg-top), var(--bg-bottom));
        padding-top: 3px; display: flex; flex-direction: column; gap: 7px;
      }
      .ap-passcard {
        --tx: #f4fbfb; --se: rgba(244, 251, 251, 0.80); --mu: rgba(244, 251, 251, 0.64);
        color: var(--tx);
        display: flex; gap: 7px; align-items: flex-start;
        border-radius: 10px; padding: 8px;
        border: 1.5px solid transparent;
        background-origin: border-box; background-clip: padding-box, border-box;
        background-image: linear-gradient(150deg, #10403c, #0d2b33),
                          linear-gradient(135deg, var(--ac), #16a8c7 55%, #22b8e0);
      }
      .ap-passcard .ap-photo { background: rgba(255, 255, 255, 0.14); }
      .ap-passcard .ap-face::before { background: rgba(244, 251, 251, 0.72); }
      .ap-passdetail .ap-qr.big { align-self: center; }
      /* De ID-kaart: foto, vier velden en een MRZ-regel -- wat een kaart tot
         een ID-kaart maakt -- met SPECIMEN erin, zodat niemand hem voor een
         echte houdt. Geen wapen, geen land: een mockup. */
      .ap-idcard-card {
        border-radius: 8px; padding: 7px 8px 6px; overflow: hidden;
        background: linear-gradient(135deg, #e3f2f7, #f8fcfd);
        border: 1px solid rgba(11, 16, 21, 0.16); color: #0b1015;
        display: flex; flex-direction: column; gap: 5px;
      }
      .ap-idcard-head {
        display: flex; justify-content: space-between; align-items: center;
        font-size: 3.8cqw; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: #16a8c7;
      }
      .ap-idcard-chip { width: 7cqw; height: 5cqw; border-radius: 2px; background: linear-gradient(135deg, #d9c27a, #b89a4a); }
      .ap-idcard-body { display: flex; gap: 7px; align-items: flex-start; }
      .ap-idcard-body .ap-photo { background: rgba(11, 16, 21, 0.08); }
      .ap-idcard-body .ap-face::before { background: rgba(11, 16, 21, 0.45); }
      .ap-idcard-fields { display: grid; grid-template-columns: auto 1fr; gap: 1px 6px; font-size: 3.9cqw; align-content: start; }
      .ap-idcard-fields span:nth-child(odd) { color: rgba(11, 16, 21, 0.55); font-weight: 600; }
      .ap-idcard-fields span:nth-child(even) { font-weight: 700; }
      .ap-idcard-mrz {
        font-family: "SFMono-Regular", Consolas, Menlo, monospace; font-size: 3.7cqw;
        letter-spacing: 0.08em; color: rgba(11, 16, 21, 0.62); white-space: nowrap; overflow: hidden;
      }
      .ap-note { font-size: 3.9cqw; color: var(--mu); text-align: center; line-height: 1.3; }

      /* Group: the making of it, then the map. */
      .ap-group { display: flex; flex-direction: column; gap: 5px; }
      .ap-field {
        display: flex; justify-content: space-between; align-items: center;
        background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.16);
        border-radius: 7px; padding: 5px 8px; font-size: 4.3cqw; color: var(--mu);
      }
      .ap-field b { color: var(--tx); }
      .ap-btn {
        background: linear-gradient(135deg, var(--ac), #22b8e0); color: #06090c;
        font-size: 4.4cqw; font-weight: 800; text-align: center; padding: 6px; border-radius: 8px;
      }
      .ap-qrbox {
        display: flex; flex-direction: column; align-items: center; gap: 4px;
        border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 9px; padding: 7px;
      }
      .ap-joined {
        display: flex; align-items: center; gap: 6px; font-size: 4.3cqw; font-weight: 700;
        background: rgba(46, 211, 183, 0.14); border-radius: 7px; padding: 5px 8px;
      }
      .ap-dot-nora { width: 8px; height: 8px; border-radius: 50%; background: #fb923c; flex: none; }
      .ap-groupmap { border-radius: 9px; overflow: hidden; }
      .ap-groupmap svg, .ap-canvas svg { width: 100%; height: auto; display: block; }

      /* Venue map editor -- upload, canvas, pin palette. */
      .ap-editor { display: flex; flex-direction: column; gap: 6px; }
      .ap-upload {
        display: flex; align-items: center; gap: 5px; font-size: 4.2cqw; color: var(--se);
        border: 1px dashed rgba(255, 255, 255, 0.3); border-radius: 8px; padding: 5px 8px;
      }
      .ap-upload .ion-icon { font-size: 5cqw; color: var(--ac); }
      .ap-canvas { position: relative; border-radius: 8px; overflow: hidden; }
      .ap-pin {
        position: absolute; width: 9cqw; height: 9cqw; border-radius: 50%;
        display: grid; place-items: center; font-size: 4.6cqw;
        background: var(--ac); color: #06090c; box-shadow: 0 2px 6px rgba(0,0,0,0.4);
      }
      .ap-pin.p1 { left: 22%; top: 26%; }
      .ap-pin.p2 { left: 68%; top: 22%; }
      .ap-pin.p3 { left: 18%; top: 72%; }
      .ap-palette {
        display: flex; gap: 5px; justify-content: center;
        border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 8px; padding: 5px;
      }
      .ap-palette span {
        width: 9cqw; height: 9cqw; border-radius: 6px; display: grid; place-items: center;
        font-size: 4.6cqw; background: rgba(255, 255, 255, 0.09); color: var(--se);
      }

      .ap-tabs {
        flex: none; display: flex; justify-content: space-around; align-items: center;
        padding: 5px 2px 7px; border-top: 1px solid var(--ru);
      }
      .ap-tab {
        display: flex; flex-direction: column; align-items: center; gap: 1px;
        font-size: 3.4cqw; font-weight: 650; color: var(--mu); flex: 1;
      }
      .ap-tab .ion-icon { font-size: 5.2cqw; }
      .ap-tab.on { color: var(--ac); }

      /* Default: the list fills up on step 1. */
      .hub-scr.playing .ap-card {
        animation: scrArrive 460ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
      }
      .hub-scr.playing .ap-card:nth-of-type(1) { animation-delay: 80ms; }
      .hub-scr.playing .ap-card:nth-of-type(2) { animation-delay: 200ms; }
      .hub-scr.playing .ap-card:nth-of-type(3) { animation-delay: 320ms; }
      .hub-scr.playing .ap-card:nth-of-type(4) { animation-delay: 440ms; }

      /* 1 · wallet , because the QR-bearing card is the point, so its chip lands on
         step 2 and the "Bekijken" pill on step 3. */
      .hub-scr.playing[data-scr="1"] .ap-chip {
        animation: scrPing 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1400ms;
      }
      .hub-scr.playing[data-scr="1"] .ap-pill {
        animation: scrPing 480ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2700ms;
      }

      /* 2 · a message: written, delivered as a push, then opened. */
      .hub-scr[data-scr="2"] .ap-push,
      .hub-scr[data-scr="2"] .ap-msg { opacity: 0; }
      .hub-scr.playing[data-scr="2"] .ap-push {
        animation: scrDrop 720ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1300ms;
      }
      .hub-scr.playing[data-scr="2"] .ap-msg {
        animation: scrArrive 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2650ms;
      }
      .hub-scr.playing[data-scr="2"] .ap-msg-cta {
        animation: scrPing 460ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 3100ms;
      }

      /* 3 · the venue builds its map: upload, then pins land one by one. */
      .hub-scr[data-scr="3"] .ap-pin { opacity: 0; }
      .hub-scr.playing[data-scr="3"] .ap-upload {
        animation: scrArrive 460ms ease-out both;
      }
      .hub-scr.playing[data-scr="3"] .ap-pin {
        animation: scrPing 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
      }
      .hub-scr.playing[data-scr="3"] .ap-pin.p1 { animation-delay: 1350ms; }
      .hub-scr.playing[data-scr="3"] .ap-pin.p2 { animation-delay: 1750ms; }
      .hub-scr.playing[data-scr="3"] .ap-pin.p3 { animation-delay: 2700ms; }

      /* 4 · a pass: the row is tapped, the pass itself opens -- photo,
         holder, number, validity -- and the QR is what you hold up. */
      .hub-scr.playing[data-scr="4"] .ap-tap {
        animation: scrTap 640ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 900ms;
      }
      .hub-scr.playing[data-scr="4"] .ap-passdetail {
        animation: scrArrive 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1350ms;
      }
      .hub-scr.playing[data-scr="4"] .ap-passdetail .ap-qr {
        animation: scrPing 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2700ms;
      }
      /* 5 · the ID card: chosen, unlocked with Face ID, and then SHOWN. */
      .hub-scr.playing[data-scr="5"] .ap-tap {
        animation: scrTap 640ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 900ms;
      }
      .hub-scr.playing[data-scr="5"] .ap-faceid {
        animation: scrPing 560ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1400ms;
      }
      .hub-scr.playing[data-scr="5"] .ap-idcard {
        animation: scrArrive 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2300ms;
      }
      .hub-scr.playing[data-scr="5"] .ap-note {
        animation: scrArrive 460ms ease-out both;
        animation-delay: 2700ms;
      }

      /* 6 · the emergency alert is the one thing allowed to insist twice. */
      .hub-scr[data-scr="6"] .ap-alert { opacity: 0; }
      .hub-scr.playing[data-scr="6"] .ap-alert {
        animation: scrDrop 720ms cubic-bezier(0.16, 0.84, 0.44, 1) both,
                   scrFlash 1500ms ease-out 2500ms;
        animation-delay: 1300ms, 2500ms;
      }

      /* 7 · making the group IS the story, so it is shown: name and button,
         then the QR someone scans, then Nora joins and you both move. */
      .hub-scr[data-scr="7"] .ap-qrbox,
      .hub-scr[data-scr="7"] .ap-joined,
      .hub-scr[data-scr="7"] .m-nora { opacity: 0; }
      .hub-scr.playing[data-scr="7"] .ap-field,
      .hub-scr.playing[data-scr="7"] .ap-btn {
        animation: scrArrive 460ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
      }
      .hub-scr.playing[data-scr="7"] .ap-btn { animation-delay: 220ms; }
      .hub-scr.playing[data-scr="7"] .ap-qrbox {
        animation: scrArrive 520ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 1300ms;
      }
      .hub-scr.playing[data-scr="7"] .ap-joined {
        animation: scrArrive 480ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2650ms;
      }
      .hub-scr.playing[data-scr="7"] .m-nora {
        animation: friendJoin 700ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
        animation-delay: 2700ms;
        transform-box: fill-box; transform-origin: center;
      }
      .hub-scr.playing[data-scr="7"] .m-me {
        animation: driftA 2400ms ease-in-out both;
        animation-delay: 3300ms;
      }

      /* 8 · discover, then booking straight from it. */
      .hub-scr.playing[data-scr="8"] .ap-book {
        animation: scrPing 460ms cubic-bezier(0.16, 0.84, 0.44, 1) both;
      }
      .hub-scr.playing[data-scr="8"] .ap-card:nth-of-type(1) .ap-book { animation-delay: 1400ms; }
      .hub-scr.playing[data-scr="8"] .ap-card:nth-of-type(2) .ap-book { animation-delay: 2000ms; }
      .hub-scr.playing[data-scr="8"] .ap-card:nth-of-type(3) .ap-book { animation-delay: 2700ms; }
      /* Per-feature icon accent colors -- vivid but kept in the dark-violet
         brand family (with a semantic amber for the emergency icon and an
         emerald for the map) so the grid feels alive without going off-theme.
         Custom props sit on the .feature-icon wrapper; the child ion-icon
         inherits them via the rule above. */
      .fi-ticket  { --fi-color: #2ed3b7; --fi-glow: rgba(46, 211, 183,  0.50); }
      .fi-pass    { --fi-color: #22b8e0; --fi-glow: rgba(34, 184, 224,  0.50); }
      .fi-id      { --fi-color: #818cf8; --fi-glow: rgba(129, 140, 248, 0.50); }
      .fi-friends { --fi-color: #22d3ee; --fi-glow: rgba(34, 211, 238, 0.45); }
      .fi-reach   { --fi-color: #f472b6; --fi-glow: rgba(244, 114, 182, 0.50); }
      .fi-map     { --fi-color: #34d399; --fi-glow: rgba(52, 211, 153, 0.45); }
      .fi-alert   { --fi-color: #fbbf24; --fi-glow: rgba(251, 191, 36, 0.50); }
      .fi-channel { --fi-color: #60a5fa; --fi-glow: rgba(96, 165, 250, 0.50); }
@media (max-width: 1000px) {
        /* A GRID, not a stack. Eight full-width rows made the page enormous
           and left the width either side of each label doing nothing. The
           features become a compact icon grid under the phone -- four across
           -- and the one you open expands across the whole row, so its
           explanation gets the full width instead of a squeezed column.

           Nothing is cloned or re-rendered: the same button is the tile and
           the expanded card, so there is still one source of truth and the
           desktop behaviour is untouched. */
        /* PHONE IN THE MIDDLE, TILES EITHER SIDE (2026-08-09). Two earlier
           passes both failed the same way: eight tiles under a full-width
           phone was taller than the screen, and phone-then-two-tile-columns
           left the tiles narrow and cramped.
           This is the desktop arrangement compressed -- which is also why it
           fits. The four .hub-node-l go in column 1 and the four .hub-node-r
           in column 3, exactly as they sit on desktop, so nothing has to be
           reordered and the layout reads the same on both. The phone takes the
           middle column and is the widest thing in the row, which is right:
           it is the demonstration, the tiles are the index. */
        /* A 3D CAROUSEL (2026-08-09). Columns of tiles either side of the
           phone never fitted a phone screen, through three attempts. This
           replaces them with a wheel: one card square-on at the front, its
           neighbours angled back either side, the rest behind them.
           THE TRICK THAT NEEDS NO NEW MARKUP: every .hub-node is put in the
           SAME grid cell (row 3, column 1), so the eight buttons stack on top
           of each other and a transform is all that separates them. Same
           eight buttons, same DOM, same script -- the desktop diagram and the
           open/close logic never learn that any of this happened.
           .hub carries the perspective, so each card's rotateY reads as depth
           rather than a squash. */
        .hub {
          aspect-ratio: auto;
          display: grid;
          grid-template-columns: 1fr;
          perspective: 760px;
          perspective-origin: 50% 62%;
          gap: 10px 0;
          /* Vertical scrolling stays the page's; the horizontal axis is the
             carousel's. Without this the browser claims the swipe first. */
          touch-action: pan-y;
          /* THE WHEEL'S BACK CARDS STICK OUT OF THE PAGE. They are already
             invisible -- hub.js sets opacity 0 and aria-hidden on anything
             more than two places from the front -- but they still occupy
             geometry, and at 390px the far-left one reached 401px, 11px past
             the viewport. That is enough for a phone to zoom the whole page
             out to fit it, which is what "the site overflows on mobile" was.
             `clip` rather than `hidden`: hidden on either axis makes the other
             axis a scroll container too, which would trap the page's own
             vertical scrolling inside the carousel. */
          overflow-x: clip;
          overflow-y: visible;
        }
        .hub-lines { display: none; }
        /* NO "TAP THE PHONE" MARK ON THE WHEEL. It sits at 88% of the hub's
           height, which on this layout is under the front card: on the venue
           page its lower edge poked out between the card and the dots as a dark
           half-circle, on the visitor page it was merely hidden behind a taller
           card. The dots and the cards are the controls here. */
        .hub-open-mark { display: none; }
        .hub-key { justify-content: center; }

        .hub-phone {
          /* Row 2, its own full-width row above the wheel. Nothing sits
             beside it any more, so it gets the width back that the two
             columns of tiles used to take. */
          grid-row: 2;
          grid-column: 1;
          position: static;
          align-self: start;
          justify-self: center;
          width: min(214px, 56vw);
          aspect-ratio: 232 / 430;
          margin: 0;
          /* The feature LEAN goes -- it is a cue about which side of the
             diagram a feature sits on, and a grid has no sides. But the phone
             should not be frozen either (user: "the phone itself doesn't move
             like on the website"). It gets a slow idle sway instead: no cursor
             is needed, and it keeps reading as an object rather than a
             picture pasted onto the page.
             The perspective comes from the perspective() FUNCTION inside the
             transform, not the `perspective` property -- .hub sets that to
             none on this layout, and the function applies to this element's
             own transform without needing anything from an ancestor. */
          transform: none;
          transition: none;
          animation: phoneSway 7s ease-in-out infinite;
          /* Back to flat so normal z-index applies again -- see below. */
          transform-style: flat;
        }
        @keyframes phoneSway {
          0%, 100% { transform: perspective(900px) rotateY(-5deg) rotateX(1.5deg) translateY(0); }
          50%      { transform: perspective(900px) rotateY(5deg) rotateX(-1deg) translateY(-7px); }
        }
        @media (prefers-reduced-motion: reduce) {
          .hub-phone { animation: none; }
        }

        /* NO 3D BODY ON THIS LAYOUT, and this is a correctness fix rather than
           a simplification. `perspective: none` on .hub above means translateZ
           resolves to nothing, so .phone-back -- a dark rectangle at inset 0
           meant to sit a --depth BEHIND the screen -- landed exactly on top of
           it, and the side walls collapsed to zero width. The phone went black
           and stopped responding to taps: the screen was still there and still
           working, with an opaque plate painted over it.
           Nothing is lost by removing them here. The phone does not rotate on
           this layout (transform: none, just above), so there is no angle at
           which any of it could be seen. */
        .phone-side,
        .phone-back { display: none; }

        /* NO SHEEN EITHER. It is a specular highlight driven by the cursor,
           and there is no cursor here -- so --sheen never gets set, the
           background sits at its 50% default, and a band sized 260% of the
           phone parks itself permanently across the middle of the screen.
           Static, it stopped reading as a reflection and just washed out the
           thing it was meant to be sitting on (user: "the glance is very
           large covering whole screen"). */
        .hub-phone::after { display: none; }

        /* ---- CAROUSEL CARDS ----------------------------------------------
           Every node in ONE grid cell, so the eight stack and only a transform
           separates them. Position, angle, opacity and z-order all come from
           hub.js, which is the only thing that knows which card is at the
           front; this rule set owns everything that does NOT change per card.
           Widths are fixed rather than fluid: a wheel needs every card the
           same size, or the ones behind read as further away than they are. */
        .hub-node {
          /* THE SAME CELL for all eight, which is what stacks them. Grid items
             sharing a cell overlap natively -- no absolute positioning, which
             for a grid child has fiddly cross-browser rules about which area
             it resolves against. position stays relative for the pulse
             ::before, exactly as it was. */
          grid-row: 3;
          grid-column: 1;
          justify-self: center;
          position: relative;
          /* THE DESKTOP DIAGRAM'S OFFSETS GO. Every card carries its place in
             that diagram -- an inline top:% and a --dx that .hub-node-l/-r turn
             into left/right -- and position: relative still applies them. So
             each card sat at its OWN spot in the shared cell: tapping through
             the wheel moved the front card up to 90px down and 30px sideways
             and pushed it over the dots (user: "cards move and seem to be
             placed on different positions"). The front slot is one place.
             !important on top only because it is an inline style. */
          top: auto !important;
          left: auto;
          right: auto;
          width: min(232px, 74vw);
          padding: 12px 13px;
          text-align: left;
          align-items: flex-start;
          /* COLUMN, not row. As a row the head and the body were laid side by
             side, so the explanation got whatever width the title left it --
             a few characters, wrapping into a tall thin ribbon (user: "the
             text gets shown in small vertical"). The head is its own row
             inside; the body belongs under it, at the card's full width. */
          flex-direction: column;
          gap: 8px;
          backface-visibility: hidden;
          transition: transform 320ms cubic-bezier(0.16, 0.84, 0.44, 1), opacity 320ms ease-out;
          --px: 0;
          --py: 0;
        }
        /* A floor under the row. The cards size it themselves -- they are
           real grid items -- but their heights differ with how much text each
           carries, so without a minimum the wheel would jog up and down a few
           pixels on every swipe. This holds it still for all the short ones. */
        .hub::after {
          content: "";
          grid-row: 3;
          grid-column: 1;
          min-height: 132px;
        }
        /* Desktop's hover/press lift would fight the wheel's own transform --
           the front card is already the emphasised one. */
        .hub-node:hover,
        .hub-node:focus-visible { transform: none; }
        .hub-head { flex-direction: row; gap: 9px; align-items: center; }
        .hub-title { font-size: 12.5px; line-height: 1.25; }
        /* Behind the front card and non-interactive -- hub.js also sets
           aria-hidden and takes them out of the tab order, so a screen reader
           and a keyboard see exactly what the eye does. */
        .hub-node.is-far { pointer-events: none; }
        /* LESS SEE-THROUGH than it was. The tint is right and the user asked
           to keep it, but at 0.18/0.10 over a translucent glass fill the page
           behind was showing through the body text and making it hard to read.
           An opaque base goes underneath so the gradient sits on something
           solid: the colour is unchanged, what is behind it no longer is. */
        .hub-node.is-front {
          background:
            linear-gradient(160deg, rgba(22, 168, 199, 0.3), rgba(34, 184, 224, 0.18)),
            linear-gradient(#141b22, #141b22);
          border-color: rgba(34, 184, 224, 0.62);
          box-shadow: 0 14px 32px rgba(22, 168, 199, 0.26);
        }
        /* The cards behind get the same treatment for the same reason -- they
           are angled and dimmed, but still ought to look like objects rather
           than holes in the page. */
        .hub-node { background: linear-gradient(#12181e, #12181e); }
        /* The wheel already says which card is chosen, by angle and opacity --
           the old is-open dimming would be a second, contradictory signal. */
        .hub.is-open .hub-node:not([aria-expanded="true"]) {
          opacity: inherit;
          filter: none;
        }
        /* The wheel's dots -- built by hub.js, so a page without the script
           never shows dead controls. Same hand as the step deck's dots.
           The touch target is the 30px button; the visible dot is drawn
           smaller inside it. */
        .hub-dots {
          grid-row: 4;
          grid-column: 1;
          display: flex; justify-content: center;
          margin-top: 2px;
        }
        .hub-dots button {
          width: 30px; height: 30px; padding: 0; border: 0;
          background: none; cursor: pointer;
          display: grid; place-items: center;
        }
        .hub-dots button::before {
          content: "";
          width: 8px; height: 8px; border-radius: 50%;
          background: var(--border-strong, rgba(255, 255, 255, 0.26));
          transition: background 180ms ease-out, transform 180ms ease-out;
        }
        .hub-dots button[aria-current="true"]::before {
          background: var(--accent);
          transform: scale(1.5);
        }
        /* Open, the front card grows its explanation in place. It does not
           move: the front slot is fixed, which is the whole point of a wheel
           over a grid -- nothing below it shifts when you choose something. */
        .hub-node[aria-expanded="true"] .hub-body { max-height: 12em; }
        .hub-node[aria-expanded="true"] .hub-title { font-size: 13.5px; }
        .hub.is-open .hub-node[aria-expanded="true"]::after {
          content: "×";
          position: absolute; top: 6px; right: 10px;
          font-size: 18px; line-height: 1; color: var(--ink-muted);
        }
}

@media (max-width: 560px) {
        .hub { perspective: 620px; }
        .hub-phone { width: min(176px, 52vw); }
        .hub-node { width: min(214px, 78vw); padding: 11px 12px; }
        .hub-title { font-size: 12px; }
}


      /* Only the selected feature's screen is rendered. The others stay in
         the DOM (hidden), so all four mockups' translated strings remain
         indexable exactly as they were when they had their own section. */
      .hub-scr[hidden] { display: none; }

      /* ---- THE HUB'S KEYFRAMES ------------------------------------------
         These live here, beside the rules that use them, and that is the whole
         point. They used to sit in index.html's inline <style> while the rules
         referencing them were extracted into this file -- so index.html
         animated correctly and app.html, with the same markup and the same
         stylesheet, animated NOTHING: every animation named a keyframe that
         did not exist on that page. A rule and its keyframes have to travel
         together; splitting them fails silently, which is why it went
         unnoticed. Verified with a check that every animation name referenced
         in this file resolves to an @keyframes in this file. */
      @keyframes driftA {
        0%   { transform: none; }
        45%  { transform: translate(-7px, 5px); }
        100% { transform: translate(-4px, 9px); }
      }

      @keyframes driftB {
        0%   { transform: none; }
        50%  { transform: translate(6px, -6px); }
        100% { transform: translate(9px, -3px); }
      }

      @keyframes friendJoin {
        0%   { opacity: 0; transform: translate(6px, 10px) scale(0.4); }
        60%  { opacity: 1; transform: translate(0, 0) scale(1.18); }
        100% { opacity: 1; transform: none; }
      }

      @keyframes hintPulse {
        0%   { box-shadow: 0 0 0 0 rgba(46,211,183,0.55); }
        70%  { box-shadow: 0 0 0 9px rgba(46,211,183,0); }
        100% { box-shadow: 0 0 0 0 rgba(46,211,183,0); }
      }

      @keyframes nodePulse {
        0%   { box-shadow: 0 0 0 0 var(--fi-glow, rgba(46,211,183,0.5)); }
        70%  { box-shadow: 0 0 0 8px transparent; }
        100% { box-shadow: 0 0 0 0 transparent; }
      }

      @keyframes scrArrive {
        0%   { opacity: 0; transform: translateY(14px) scale(0.97); }
        100% { opacity: 1; transform: none; }
      }

      @keyframes scrDrop {
        0%   { opacity: 0; transform: translateY(-120%); }
        18%  { opacity: 1; transform: translateY(6%); }
        30%  { transform: translateY(0); }
        100% { opacity: 1; transform: translateY(0); }
      }

      @keyframes scrTap {
        0%   { transform: scale(0.4); opacity: 0; }
        35%  { transform: scale(1.1); opacity: 1; }
        100% { transform: scale(1.7); opacity: 0; }
      }

      @keyframes scrPing {
        0%   { transform: scale(0.4); opacity: 0; }
        40%  { transform: scale(1.25); opacity: 1; }
        70%  { transform: scale(0.92); }
        100% { transform: scale(1); opacity: 1; }
      }

      @keyframes stepShow {
        0%   { opacity: 0; transform: translateY(6px); }
        12%  { opacity: 1; transform: none; }
        78%  { opacity: 1; }
        100% { opacity: 0; }
      }

      @keyframes stepStay {
        0%   { opacity: 0; transform: translateY(6px); }
        12%  { opacity: 1; transform: none; }
        100% { opacity: 1; }
      }

      /* ---- The hero banner ------------------------------------------
         Moved here from index.html's inline styles on 2026-08-12, when the
         visitor page gained the same artwork. Both pages <link> this file,
         so the geometry and the two ambient animations now have exactly one
         definition. ---- */
      /* The app header's venue line-art, sized for the site. INVERTED fade
         versus the app: there the logo owns the left so the drawing peaks in
         the middle; here the hero text owns the middle, so the vignettes
         live at the edges and go quiet behind the words. Absolute + no top:
         it sits exactly where it would have laid out (over the hero) while
         costing no height, and overflows wider than .wrap on purpose. */
      .hero-banner { position: absolute; left: 0; right: 0; z-index: 0; pointer-events: none; }
      .hero-banner svg { width: 100%; height: clamp(150px, 22vw, 280px); display: block; }
      .hero { position: relative; z-index: 1; }
      /* OP DE TELEFOON BLIJFT HIJ STAAN. Hij werd hier verborgen omdat de
         tekening 1440 breed is en op 375 pixels het MIDDEN uitsneed -- en daar
         staat een leeg stuk lucht tussen het reuzenrad en het paviljoen, dus
         wat je zag was een lege band. Niet de tekening was het probleem maar
         welk deel ervan.
         De uitsnede wordt nu links uitgelijnd (zie de regel bij data-banner in
         index.html): dan valt de poort, de glijbaan en het reuzenrad in beeld,
         precies het dichtstbevolkte stuk. Iets lager, want op een smal scherm
         mag hij minder van de hero opeten. */
      /* Hoogte volgt de breedte, want op de telefoon past de hele tekening
         (preserveAspectRatio meet) en die heeft een eigen verhouding. Een
         vaste hoogte zou er weer lucht boven en onder omheen zetten. */
      @media (max-width: 700px) { .hero-banner svg { height: auto; } }
      /* Ambient, not attention-seeking: the wheel turns at fairground pace
         (a full revolution per minute) and the pool breathes. Nothing moves
         for anyone who asked for less motion -- same rule as every other
         animation on this page. transform-box makes the rotation happen
         about the wheel's own hub instead of the SVG origin. */
      @media (prefers-reduced-motion: no-preference) {
        .banner-wheel {
          transform-box: fill-box; transform-origin: center;
          animation: bannerWheelTurn 60s linear infinite;
        }
        .banner-wave { animation: bannerWaveDrift 7s ease-in-out infinite alternate; }
        .banner-wave-late { animation-delay: -3.5s; }
      }
      @keyframes bannerWheelTurn { to { transform: rotate(360deg); } }
      @keyframes bannerWaveDrift { from { transform: translateX(-7px); } to { transform: translateX(7px); } }

/* ===========================================================================
   DE HUB WORDT EEN LIJST (2026-08-29)
   ===========================================================================
   Wat hier stond: acht zwevende witte kaartjes met een pastelvakje en een
   gekleurde stip, met haarlijnen verbonden aan een telefoon in het midden.
   Van alles op deze pagina was dit het blok dat er het meest uitzag alsof een
   machine het had bedacht -- zwevende afgeronde kaartjes met pastel-iconen is
   precies het patroon dat we bij de knoppen, de stapkaarten en de eyebrow al
   hebben weggehaald. Hier stond het nog, acht keer tegelijk.

   Wat het wordt: een LIJST naast het toestel. Acht regels typografie, een
   haarlijn ertussen, de gekozen regel in inkt. Alles wat het diagram deed
   blijft werken -- klikken wisselt het scherm, het toestel opent de laag --
   maar de kaartjes, de stippen, de pastelvakjes en de spaken zijn weg.

   Waarom een lijst en niet nog een animatie: dit blok is een INHOUDSOPGAVE.
   Het zegt "dit zijn de acht dingen die de app doet", en dat is precies wat
   een lijst goed doet en een diagram slecht -- in een radiaal diagram is er
   geen eerste en geen laatste, terwijl deze acht wel degelijk een volgorde
   hebben (van ticket tot verkoopkanaal).

   Alleen boven 1000px. Daaronder blijft het wiel dat er al was: op een
   telefoon is dat een opgelost probleem en het heeft een vaste hoogte, wat
   drie eerdere pogingen niet lukte.
   =========================================================================== */
@media (min-width: 1001px) {
  .hub {
    aspect-ratio: auto;
    /* De volle breedte van het paneel: met een eigen plafond bleef er rechts
       van het toestel een strook over die nergens bij hoorde. */
    max-width: 100%;
    display: grid;
    grid-template-columns: 1fr 300px;
    column-gap: clamp(36px, 5vw, 72px);
    row-gap: 0;
    align-items: center;
    perspective: 1200px;
  }
  /* De spaken verbonden kaartjes die er niet meer zijn. */
  .hub-lines { display: none; }

  /* Geen wikkel om de knoppen -- ze zijn directe kinderen van .hub -- dus de
     plaatsing gebeurt met het raster zelf. Dat scheelt een markup-ingreep in
     een blok waar de JS op de structuur leunt. */
  .hub-key { grid-column: 1; grid-row: 1; }
  .hub-node { grid-column: 1; }
  .hub-node:nth-of-type(1) { grid-row: 2; }
  .hub-node:nth-of-type(2) { grid-row: 3; }
  .hub-node:nth-of-type(3) { grid-row: 4; }
  .hub-node:nth-of-type(4) { grid-row: 5; }
  .hub-node:nth-of-type(5) { grid-row: 6; }
  .hub-node:nth-of-type(6) { grid-row: 7; }
  .hub-node:nth-of-type(7) { grid-row: 8; }
  .hub-node:nth-of-type(8) { grid-row: 9; }

  .hub-node {
    /* relative, niet static: het hover-pijltje hieronder heeft een houvast
       nodig. Maar dan moeten de plaatsingswaarden van het diagram er wel
       expliciet uit, anders schuiven de inline `top` en de --dx uit
       .hub-node-l/-r de regels alsnog scheef. */
    position: relative;
    top: auto !important; left: auto !important;
    right: auto !important; bottom: auto !important;
    /* Een ondergrens op de regelhoogte. Gemeten viel er precies een rij terug
       naar 22px terwijl de rest op 61 stond, waardoor twee regels over elkaar
       vielen; met align-items: center rekt een rij niet mee en is de inhoud
       het enige dat de hoogte bepaalt. Deze grens maakt het ritme gelijk en
       sluit de overlap uit, ongeacht welke regel kort uitvalt. */
    min-height: 56px;
    justify-content: center;
    width: 100%;
    transform: none;
    background: none;
    border: 0;
    border-top: 1px solid var(--border);
    border-radius: 0;
    box-shadow: none;
    padding: 14px 4px;
    gap: 3px;
    transition: color 180ms ease-out;
  }
  .hub-node:last-of-type { border-bottom: 1px solid var(--border); }
  .hub-node:hover { background: none; }

  /* Het icoonvakje was een pastelvierkantje achter een glyph -- het luidruchtigste
     detail van het blok, acht keer herhaald. Het icoon blijft, het vakje niet. */
  .hub-dot {
    width: 20px; height: 20px; border-radius: 0;
    background: none; font-size: 17px;
    color: var(--fi-color, var(--accent-strong));
  }
  .hub-head { gap: 11px; align-items: center; }
  .hub-title {
    font-family: "Fraunces", Georgia, serif;
    font-weight: 600; font-size: 17px; color: var(--ink-soft);
  }
  .hub-node[aria-expanded="true"] .hub-title { color: var(--ink); font-weight: 700; }
  .hub-node:hover .hub-title { color: var(--ink); }
  /* Het pijltje verschijnt bij zweven: dit opent iets, en dat mag je zien
     aankomen. Het staat rechts, waar het venster vandaan komt. */
  .hub-node::after {
    content: "\2192";
    position: absolute; right: 8px; top: 50%; margin-top: -11px;
    font-size: 16px; color: var(--accent-strong);
    opacity: 0; transform: translateX(-6px);
    transition: opacity 180ms ease-out, transform 180ms ease-out;
  }
  .hub-node:hover::after, .hub-node:focus-visible::after { opacity: 1; transform: none; }
  @media (prefers-reduced-motion: reduce) {
    .hub-node::after { transition: none; }
  }

  /* De omschrijving staat NIET in het overzicht. Toen hij hier wel stond had
     het venster niets meer te vertellen -- je had alles al gelezen voordat je
     klikte. Eén plek voor de uitleg, en dat is het venster. Hij blijft wel in
     de markup staan: voorlezen en zoeken op de pagina vinden hem gewoon. */
  .hub-body { display: none; }

  /* Het toestel staat in zijn eigen kolom en heeft de scene niet meer nodig. */
  .hub {
    /* Ruimte voor het toestel, dat hieronder uit de rasterstroom wordt gehaald. */
    grid-template-columns: 1fr;
    padding-right: 300px;
  }
  .hub-phone {
    position: absolute;
    /* Kleiner en verder naar rechts: op 280px trok het toestel de aandacht
       weg van de lijst die het blok eigenlijk is, en het stond te ver naar
       links doordat de hub nog zijn eigen breedteplafond had. */
    /* left EXPLICIET terug naar auto. De basisregel zet left: 50%, en met
       left+right+width alle drie gezet is de box overbepaald -- dan negeert de
       browser `right` en bleef het toestel in het midden staan. Precies
       dezelfde fout als in de laag: wie een regel overneemt, neemt alles over
       wat die zette. */
    left: auto; right: 0; top: 50%;
    width: 232px;
    transform: translateY(-50%) rotateY(-6deg) rotateX(2deg);
  }
  .hub-node::before { display: none; }
  .hub-open-mark { left: auto; right: 108px; top: auto; bottom: 6px; transform: none; }
  .hub:hover .hub-open-mark,
  .hub:focus-within .hub-open-mark { transform: scale(1.06); }
}

/* ---- HET TWEE-TOONS BLOK. Gedeeld door beide pagina's.
   Hoofdstuk 01 (het ticket) doet dit al: een kleurvlak met de kop erop, en
   ernaast op de drager de inhoud. De gebruiker wil dat patroon ook bij de
   blokken met VEEL TEKST, waar het de kop van de inhoud scheidt zonder er een
   kader omheen te zetten.
   Niet bij elk blok: alleen waar genoeg tekst staat om te scheiden. Overal
   toepassen zou er weer een raster van maken.
   ---- */
.duo {
  display: grid;
  /* DE GEKLEURDE HELFT LIGT RECHTS. De kop stond links, en dan lees je op deze
     pagina drie blokken achter elkaar met de tekst aan dezelfde kant -- die
     erboven hebben hun kopij ook links. Omdraaien zet de kop tegenover de vorige
     en geeft de reeks ritme, wat precies is waarom .block-reverse bestaat.
     De VOLGORDE in de markup verandert niet: de kop staat er eerst en wordt ook
     eerst voorgelezen, wat klopt -- hij zegt waar de rest over gaat. Alleen de
     kolom waarin hij landt verschuift. */
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  border-radius: 24px;
  overflow: hidden;
  background: var(--glass-strong);
}
.duo-side { grid-column: 2; grid-row: 1; }
.duo-main { grid-column: 1; grid-row: 1; }
.duo-side {
  background: var(--duo, #dbeef8);
  padding: clamp(28px, 3.6vw, 44px) clamp(24px, 3vw, 38px);
  display: flex; flex-direction: column; justify-content: center; gap: 12px;
}
.duo-side h2 {
  margin: 0; font-size: clamp(24px, 2.8vw, 32px); line-height: 1.12;
  color: var(--ink);
}
.duo-side p { margin: 0; font-size: 15px; line-height: 1.6; color: var(--ink); opacity: 0.82; }
.duo-main {
  padding: clamp(26px, 3.4vw, 42px) clamp(24px, 3vw, 38px);
  display: flex; flex-direction: column; gap: 22px;
}
@media (max-width: 800px) {
  .duo { grid-template-columns: 1fr; }
  /* Op één kolom telt alleen de volgorde, en dan hoort de kop weer bovenaan --
     anders komt de opsomming voor de titel die hem aankondigt. */
  .duo-side { grid-column: 1; grid-row: 1; }
  .duo-main { grid-column: 1; grid-row: 2; }
}

/* De drie niveaus in het privacyblok. Geen tabel: een tabel suggereert dat
   twee kolommen hetzelfde soort ding bevatten, en dat is hier juist niet zo --
   de eerste is de regel, de tweede het minimum dat nodig is om te werken, en
   de derde bestaat alleen als iemand hem zelf aanzet. */
.tier { display: flex; flex-direction: column; gap: 7px; }
.tier-head {
  display: flex; align-items: center; gap: 9px;
  font-size: 12.5px; font-weight: 700; letter-spacing: 0.05em;
  text-transform: uppercase; color: var(--ink-muted);
}
.tier-head::before {
  content: ""; width: 9px; height: 9px; border-radius: 50%;
  background: var(--tier, var(--ink-muted)); flex: none;
}
.tier ul { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 6px 8px; }
.tier li {
  font-size: 13.5px; color: var(--ink-soft);
  background: rgba(11, 16, 21, 0.045);
  border-radius: 8px; padding: 5px 10px;
}
.tier-note { margin: 2px 0 0; font-size: 13px; line-height: 1.55; color: var(--ink-muted); }
.tier-opt li { background: rgba(22, 168, 199, 0.1); color: var(--ink); }

/* ===========================================================================
   HET WIEL OP DE TELEFOON WORDT ÉÉN KAART (2026-08-29)
   ===========================================================================
   Wat er stond: de gekozen kaart recht vooruit, de buren schuin naar achteren
   gedraaid, de rest daar weer achter. Op de donkere pagina waarvoor het
   gemaakt is werkte dat; op de lichte drager zijn die buren donkergrijze
   platen geworden die achter de voorste vandaan steken -- de gebruiker:
   "shows the cards a bit weird en overlapped".

   Twee dingen mis, en het tweede is de eigenlijke: de kaarten dragen NOG
   STEEDS de donkere stijl. De lijstweergave die ik bouwde geldt alleen boven
   1000px, dus alles hieronder bleef staan zoals het was toen de pagina zwart
   was. Vandaar donkere platen op een lichte pagina.

   Nu: één kaart tegelijk, in de stijl van de rest van de site, en vegen om
   naar de volgende te gaan. De buren zijn er nog -- de veegcode van hub.js
   rekent met hun posities -- maar ze zijn onzichtbaar en staan klaar aan de
   kant waar ze vandaan komen, zodat de wissel als schuiven leest en niet als
   verspringen.
   =========================================================================== */
@media (max-width: 1000px) {
  /* De kaart is papier, geen donker vlak. */
  .hub-node {
    background: var(--glass-strong) !important;
    border: 1px solid var(--border) !important;
    border-radius: 16px;
    box-shadow: 0 6px 20px rgba(11, 16, 21, 0.1) !important;
    color: var(--ink);
  }
  .hub-node .hub-title { color: var(--ink); }
  .hub-node .hub-body { color: var(--ink-soft); }

  /* ALLEEN DE VOORSTE IS TE ZIEN. De rest houdt zijn plek in de rij -- de
     veeglogica telt met ze -- maar is doorzichtig en niet aan te wijzen. */
  .hub-node {
    opacity: 0;
    pointer-events: none;
    transition: opacity 260ms cubic-bezier(0.2, 0.8, 0.3, 1),
                transform 320ms cubic-bezier(0.2, 0.8, 0.3, 1);
  }
  .hub-node.is-front {
    opacity: 1;
    pointer-events: auto;
    /* Recht vooruit: de hoek en de lift van het wiel zeiden "er staan er meer
       achter", en dat is nu niet meer waar. */
    transform: none !important;
    background: var(--glass-strong) !important;
    border-color: var(--accent-strong) !important;
    box-shadow: 0 10px 28px rgba(22, 168, 199, 0.18) !important;
    width: min(300px, 84vw);
  }
  /* De buren staan klaar aan de kant waar ze vandaan komen, zodat de wissel
     als schuiven leest. hub.js zet --px op een negatieve of positieve waarde
     naar gelang de kant; die gebruiken we alleen nog voor de RICHTING. */
  .hub-node:not(.is-front) {
    transform: translateX(var(--px, 0)) scale(0.94) !important;
    /* !important, net als de transform: hub.js zet opacity: 0.5 INLINE op
       de buren voor het wiel, en een inline waarde wint van de 0 hierboven.
       Zo stonden de buren op halve sterkte achter de voorste -- de gebruiker:
       "the cards get stacked when pressed". */
    opacity: 0 !important;
  }

  /* De kaart mag hoger worden nu er maar één staat: de omschrijving hoeft
     niet meer op drie regels afgekapt te worden om naast zijn buren te passen. */
  /* De zichtbare kaart toont zijn omschrijving zonder dat je hem eerst moet
     openklappen: er staat er nog maar één, dus er valt geen ruimte te sparen. */
  .hub-node.is-front .hub-body {
    -webkit-line-clamp: 10;
    max-height: 14em;
    opacity: 1;
    font-size: 13px; line-height: 1.5;
    margin-top: 4px;
  }
}

/* ---- HET PREMIUM-BLOK. Het enige donkere vlak op de bezoekerspagina.
   Het Tixit Pass-blok zag er precies uit als het kortingsbonnenblok erboven --
   dezelfde drie kaarten in hetzelfde raster -- terwijl dit het enige
   lidmaatschap is. Inkt op de lichte drager is het signatuurcontrast
   van de standaard, en hier verdient het zijn plek: je ziet in één oogopslag
   dat dit iets anders is dan de blokken eromheen.
   ---- */
.premium {
  background: var(--frame-bg, #0b1015);
  color: #f2f7f9;
  border-radius: 24px;
  padding: clamp(28px, 4vw, 48px) clamp(24px, 3.6vw, 46px);
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  gap: clamp(24px, 4vw, 56px);
  align-items: center;
}
.premium-head { display: flex; flex-direction: column; gap: 12px; }
/* "Tijdelijk gratis" als opgeplakt label, in de vorm van het aanbevolen-label
   op de prijzenpagina: pil, donkere inkt, iets scheef. Er staat bewust GEEN
   prijs, ook niet doorgestreept -- het verdienmodel van de pass ligt nog open. */
.premium-badge {
  align-self: flex-start; margin: 0;
  background: #2ed3b7; color: #06212a;
  font-size: 13px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 5px 14px; border-radius: 999px; white-space: nowrap;
  transform: rotate(-2.5deg);
  box-shadow: 0 4px 14px rgba(46, 211, 183, 0.3);
}
.premium h2 {
  margin: 0; font-size: clamp(26px, 3.2vw, 38px); line-height: 1.08; color: #f7fbfc;
}
.premium-sub { margin: 0; font-size: 15px; line-height: 1.6; color: #b9c9d1; }
.premium-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 18px; }
.premium-list li { display: flex; gap: 14px; align-items: flex-start; }
.premium-list .ion-icon { font-size: 21px; color: #2ed3b7; flex: none; margin-top: 2px; }
.premium-list b { display: block; font-size: 15.5px; font-weight: 700; color: #f2f7f9; margin-bottom: 3px; }
.premium-list span { font-size: 14px; line-height: 1.55; color: #a9bcc5; }
@media (max-width: 800px) {
  .premium { grid-template-columns: 1fr; gap: 26px; }
}

/* De extra alinea onder de privacybelofte: wat je zelf aanzet, los van de
   regel erboven. Ingesprongen met een lijntje, zodat hij als aanvulling leest
   en niet als een tweede belofte. */
.promise-extra {
  margin: 16px 0 0;
  padding-left: 16px;
  border-left: 2px solid var(--border-strong);
  font-size: 14.5px; line-height: 1.6; color: var(--ink-soft);
}

/* De samengevatte vorm van het privacyblok: een regel per niveau in plaats van
   een rij chips. Het blok geeft de VORM; het privacybeleid geeft de uitleg. */
.tier-line { margin: 0; font-size: 14px; line-height: 1.55; color: var(--ink-soft); }
.tier-more { margin: 4px 0 0; font-size: 13.5px; color: var(--ink-muted); }
.tier-more a { color: var(--accent-strong); }
