/* ══════════════════════════════════════════════════════════════
   Meetback — Cards home ("Passport", Seal-ring)

   Built from the pinned reference
   design-system/products/meetback/assets/cards-home.html @ e77694f8,
   and design-system/products/meetback/specs/cards-home.md @ 67b496a0.

   Lifted verbatim from the reference except where the reference's local
   values already exist as design tokens — those now read from tokens.css so
   the two cannot drift:
     --radius:22px   → var(--r-lg)          (Critic invariant: 22px is load-bearing)
     --display       → var(--font-display)  ('Fraunces', serif)
     --body          → var(--font-body)     (Inter stack)
   The reference's --ok is dropped: nothing in the markup uses it, and its value
   disagrees with the token of the same name. Everything else keeps the
   reference's own names so this file can be read side-by-side with it.

   The reference is a standalone page: its .app is one flex column holding the
   controls, the lane AND the tab bar. The shell splits that across #app and its
   sibling #nav, so the flex column moves up to <body> and the same layout falls
   out — one column, lane takes the slack, tab bar pinned to the base.
   ══════════════════════════════════════════════════════════════ */

:root {
  --bg: var(--page);
  --surface2: var(--ch-surface2);
  --border: var(--ch-border);
  --text: var(--ch-text);
  --muted: var(--ch-muted);
  --faint: var(--ch-faint);
  --accent: var(--ch-accent);
  --accent2: var(--ch-accent2);
  --seal: var(--ch-seal);
  --stock1: var(--ch-stock1);
  --stock2: var(--ch-stock2);
  --edge: rgba(139, 147, 165, .30);

  --sat: env(safe-area-inset-top, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
  --sal: env(safe-area-inset-left, 0px);
  --sar: env(safe-area-inset-right, 0px);
}

/* The carousel track is wider than the screen — the neighbouring cards hang off
   both edges, and that clipping IS the peek. In the reference's standalone page
   nothing clips it, so it pushes the document sideways; caught at 375×667 and
   430×932. #app clips it too, for the same reason. */
/* The page colour lives on #app, not body — body's background is the CANVAS
   colour, declared per screen by body:has([data-screen=…]) rules in each
   screen's own stylesheet (see tokens.css). #app spans the whole viewport, so
   the band under the status bar is still the page colour and the top stays
   seamless; what changes is only what shows BEYOND the viewport. */
body { overflow-x: hidden; }
#app { background: var(--bg); }

/* ══════════════════════════════════════════════════════════════
   Q40 — THE CANVAS FOLLOWS THE FRONT CARD, MUTED.

   Owner, 2026-08-08, ruling on the blend they chose for this screen (C's canvas
   + A's stack): the screen takes the front card's hue heavily washed out, so the
   full-strength accent stays on the CARD and the screen only echoes it.

   --wash-lt / --wash-dk are written to <html> by paintCanvas() in
   screens/cards-home.js, from wash() in core/theme.js. They are DERIVED from the
   accent, so two cards of different colours give two different canvases; a fixed
   tint here would be the "stays neutral" answer the owner declined, wearing this
   one's name.

   ⚠ BOTH SURFACES MOVE OR NEITHER SHOULD. #app is the canvas the card sits on;
   BODY is the band BELOW the layout viewport — the strip Q7 spent three
   diagnoses on, reachable only because a body background propagates to the CSS
   canvas. Moving one without the other puts a hard line across the screen at
   exactly the place the owner has already photographed one. --edge-top goes with
   them so the status bar tints to match: three knobs on one colour, and Q7's
   note in tokens.css is explicit that leaving any of them behind is the bug.

   THE FALLBACK IS THE OLD PAGE COLOUR, not a literal. Before the first paint —
   and in the empty, unavailable and welcome states, which have no front card —
   these resolve to what this screen looked like yesterday. */
body:has([data-screen="cards-home"]) #app,
body:has([data-screen="cards-home"]) { background-color: var(--wash-lt, var(--bg)); }
[data-screen="cards-home"] { --edge-top: var(--wash-lt, var(--bg)); }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) body:has([data-screen="cards-home"]) #app,
  :root:not([data-theme="light"]) body:has([data-screen="cards-home"]) {
    background-color: var(--wash-dk, var(--bg));
  }
  :root:not([data-theme="light"]) [data-screen="cards-home"] {
    --edge-top: var(--wash-dk, var(--bg));
  }
}
:root[data-theme="dark"] body:has([data-screen="cards-home"]) #app,
:root[data-theme="dark"] body:has([data-screen="cards-home"]) {
  background-color: var(--wash-dk, var(--bg));
}
:root[data-theme="dark"] [data-screen="cards-home"] {
  --edge-top: var(--wash-dk, var(--bg));
}

/* The cross-fade. 200ms is the motion budget LANGUAGE.md states as a number, and
   the owner's ruling names it: the canvas "cross-fades inside the 200ms budget
   as the stack moves". A custom property cannot itself be transitioned, but the
   background-color that READS it can, which is why the wash is delivered as two
   resolved hex values rather than as a colour-mix computed here. */
body:has([data-screen="cards-home"]) #app,
body:has([data-screen="cards-home"]) { transition: background-color .2s ease; }

@media (prefers-reduced-motion: reduce) {
  body:has([data-screen="cards-home"]) #app,
  body:has([data-screen="cards-home"]) { transition: none; }
}

/* ── Shell layout: the reference's .app column, split across #app + #nav ── */
#app {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding-left: var(--sal);
  padding-right: var(--sar);
  /* Clip the track here rather than leaving it to overflow propagation from
     <body>: the cards either side of the active one hang past both edges, and
     uncontained they widen the layout viewport instead of being trimmed by the
     screen — that trimming IS the peek. Vertical is unaffected in
     practice: box-shadow does not contribute to scrollable overflow, and the
     one shadow that reaches this box's bottom edge is painted over by the
     opaque tab bar sitting directly beneath it. */
  overflow-x: hidden;
}
#nav { flex: 0 0 auto; }

.cards-home {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

.controls {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: calc(14px + var(--sat)) 18px 6px;
}
.pill {
  background: var(--text); color: var(--surface); font-size: 12px; font-weight: 700;
  letter-spacing: .08em; padding: 6px 12px; border-radius: var(--r-pill);
}
.ctrl-group { display: flex; gap: 10px; }
.round {
  width: 44px; height: 44px; border-radius: 50%;
  background: var(--surface); border: 1px solid var(--border);
  display: grid; place-items: center; color: var(--text);
  box-shadow: 0 2px 6px rgba(0, 0, 0, .05);
  cursor: pointer; font: inherit;
}
.round svg { width: 18px; height: 18px; }
.round:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }


/* The carousel's announcement channel. `aria-label` on the lane is not
   announced when it changes, and it was the only place this screen said which
   card you were on — so a step was silent. Visually hidden and OUT OF FLOW, so
   it costs no layout in any state; `top/left` are pinned rather than left to the
   static position, which would sit at the bottom of the column and could push
   the shell 1px past the viewport. */
.ch-live {
  position: absolute; top: 0; left: 0;
  width: 1px; height: 1px; margin: 0; padding: 0; border: 0;
  overflow: hidden; white-space: nowrap;
  clip: rect(0 0 0 0); clip-path: inset(50%);
}

/* ── Q40, THE STACK — "the stack IS the scroll" ──────────────────────────
   Owner, 2026-08-08, choosing (a) over the alternative that kept centring:
   "the whole screen scrolls and the cards overlap as they pass, Apple Wallet's
   behaviour… touch-action pan-y comes off the lane and the page owns the
   vertical gesture."

   ⚠ `touch-action: pan-y` IS GONE AND ITS ABSENCE IS THE FEATURE. It used to
   say: compositor, keep vertical, hand me horizontal — which was exactly right
   for a carousel and is exactly wrong now. The browser owns both axes here.

   ⚠ NO scroll-snap, AND THAT IS RULED RATHER THAN FORGOTTEN. Same answer: "the
   stack can never sit still on one card. Do not add a snap-to-card that
   restores centring; that is option (b) smuggled back in." A
   `scroll-snap-type: y mandatory` on this element would be that option arriving
   dressed as polish. */
.lane {
  flex: 1 1 0;
  min-height: 0;
  position: relative;
  overflow-y: auto;
  overflow-x: hidden;
  /* The stack is the whole of this screen's scrolling; a rubber-band that
     chains to an ancestor would drag the fixed shell. */
  overscroll-behavior-y: contain;
  -webkit-overflow-scrolling: touch;
  padding: 8px 0 12px;
  /* ── Q61 — THE LANE IS THE ROOM, AND IT SAYS SO IN A UNIT ────────────────
     Owner, 2026-08-08, with a screenshot of the installed app: "the card now is
     too big for the interface" — Preview and Edit sliced in half by the tab bar.

     REPRODUCED BEFORE ANYTHING WAS CHANGED, at a 34px bottom inset: the action
     row ran 49px under the bar at 375x667, 12px at 393x852, and just cleared at
     430x932. At an inset of 0 it cleared everywhere, which is why no browser in
     this repo had ever seen it and why the two candidates had to be separated by
     measurement rather than by argument. The other candidate — Q47's short
     launch — is NOT needed to produce this and is not the cause.

     WHAT BROKE IT: until v75 the card was bounded by `max-height: 100%` against
     THE LANE. Q40 made each slide exactly 80dvh, so that clamp resolves against
     a box that is 80dvh by construction and can never bind. 80dvh is a share of
     the SCREEN; the lane is the screen minus the controls row, minus the tab bar,
     minus the safe area — and the difference is the part of the card that goes
     under the bar.

     THE FIX IS A CONSTRAINT, NOT A NUMBER. `80dvh` stays as what the card ASKS
     for, so Q38's ruling that the card scales with the phone is untouched and no
     fitted literal comes back. `container-type: size` lets the SLIDE express what
     it can HAVE in the lane's own units. */
  container-type: size;

  --slide-w: min(86vw, 380px);
  /* Q64 — 56dvh, DOWN FROM 80, AND Q38 IS SPENT RATHER THAN IGNORED.
     Owner, 2026-08-09: "make cards, smaller vertically. Roughly 30% for the
     better proportions on the screen and visibility of the Wallet menu."
     80 × 0.7 = 56.

     ⚠ THE SECOND REASON IS THE ONE THAT WAS MEASURED. "Visibility of the Wallet
     menu" is the stack behind the front card, and before this change there were
     TWELVE PIXELS of the next card on a 375×667 — the fold cut it off, so a
     wallet of passes read as one sheet. The height is the lever; the peek is the
     result, and the result is what the done-when asks for.

     ⚠ WHAT Q38 RULED AND WHAT SURVIVES. Q38 removed a 600px cap so the card
     would SCALE WITH THE PHONE rather than being a fixed size. That survives
     exactly: this is still a share of the viewport, still `dvh`, still no cap.
     What is spent is the SHARE — 80% of the screen was Q38's number and it is
     now 56%. Recorded in LANGUAGE.md §3 rather than absorbed. */
  --slide-h: 56dvh;
  /* HOW FAR ONE CARD IS FROM THE NEXT. Declared once, here, and read by JS
     (stackStride) so the layout and the index maths can never disagree about
     which card is at the front. Smaller than --slide-h by construction: the
     difference IS the overlap.
     ⚠ JS MEASURES TWO SLIDE OFFSETS RATHER THAN READING THIS TOKEN. An
     unregistered custom property computes to its token TEXT, so getPropertyValue
     hands back the string "26dvh" and parseFloat turns it into 26 pixels — a
     number with no relationship to the layout. This declaration is the layout's
     source; the measurement is the maths's. */
  /* Q64 — 18dvh, HOLDING THE RATIO RATHER THAN THE VALUE. 26/80 was 0.325;
     18/56 is 0.321. Left at 26 against a 56dvh card the overlap would have gone
     from a third to nearly half, and the wallet would have read TIGHTER after a
     change asked for to open it up — the opposite of what was requested. */
  --stack-stride: 18dvh;
}
/* Keyboard focus draws on the CARD, not the lane — the lane is now a scrolling
   region and a ring around the viewport is not what a tap or an arrow key
   should produce. Unchanged in intent from the carousel; only the reason for
   the lane being focusable has changed (it scrolls rather than steps). */
.lane:focus { outline: none; }
.lane:focus-visible .slide.is-active .passport {
  outline: 2px solid var(--accent); outline-offset: 4px;
}

/* ── the stack ──
   A plain block column. Every card starts --stack-stride below the one before
   it and is --slide-h tall, so each card covers all but a stride of the card
   above: the overlap is a consequence of the geometry, exactly as the carousel's
   peek was. Later cards paint over earlier ones by tree order, which is what
   makes a card "pass" the one before it as the lane scrolls.

   ⚠ NOT absolutely positioned, and nothing here transforms. The carousel's
   track was positioned and JS owned its x; this is in flow and the browser owns
   its y. The scroll height falls out of the layout — (n-1) strides plus one
   card — instead of being computed. */
/* ⚠ THE TRACK CARRIES TRAILING ROOM, AND Q64 IS WHY IT HAD TO. The lane's
   travel is (n-1) strides plus one card, minus the lane's own height; the front
   index is round(scrollTop / stride). Those two only agree if the travel reaches
   (n-1) strides. At 80dvh the card overflowed the lane enough that it did —
   MEASURED at 375×667 with four cards: travel 497 against a 166 stride, and the
   last card took the front. At 56dvh it does NOT: travel fell to 208 against a
   120 stride, so round(208/120) is 2 and slides 3 and beyond could never reach
   the front. Nothing visual said so; the canvas simply stopped following, and
   the Share hub reads the same `.is-active`.

   So the track ends with the room a last card needs to climb into the front
   position: the lane's height less one card. `100cqh` is the lane's own height —
   the container Q61 established — so this is the same measurement the slide's
   max-height already uses rather than a second guess at it. `max(0px, …)` keeps
   it from going negative on a viewport where the card is taller than the lane,
   which is the case Q61 exists for. */
.track {
  display: block; width: 100%;
  padding-bottom: max(0px, calc(100cqh - var(--slide-h)));
}
/* ⚠ `max-height`, NOT `height: min(...)`. If a browser does not know `cqh` it
   drops this declaration and the slide keeps its 80dvh — today's behaviour,
   which is wrong on a notched phone but is a CARD, whereas an invalidated
   `height` would leave the slide with no height at all and the wallet would
   collapse to nothing. A fallback has to fail toward the old bug, never toward
   a blank screen. */
.track > .slide {
  width: var(--slide-w);
  height: var(--slide-h);
  max-height: 100cqh;
  margin-inline: auto;
  min-height: 0;
  display: flex; align-items: center; justify-content: center;
}
.track > .slide + .slide { margin-top: calc(var(--stack-stride) - var(--slide-h)); }

/* The card you are on is the one at full strength; the rest recede, the way the
   carousel's neighbours did. One property, 200ms, inside the motion budget
   LANGUAGE.md §2.3 states as a number. */
.track > .slide .passport { transition: opacity .2s ease-out; }
.track > .slide:not(.is-active) .passport { opacity: .82; }
@media (prefers-reduced-motion: reduce) {
  .track > .slide .passport { transition: none; }
}
.track img { -webkit-user-drag: none; user-drag: none; }

/* ── Tab bar — renders into the shell's single #nav ──
   Drawn by the cards-home reference, but it is NOT this screen's furniture:
   core/tabbar.js renders it and every tab destination uses it, /scan included.
   The rules stay in this file because a stylesheet of its own would need a
   <link> in public/index.html, which builds do not edit — and this file is
   loaded on every page and already declares the vars below, so the bar is
   never conditional on the cards screen having rendered. Move them out with
   the <link> when something else forces index.html open. */
.tabbar {
  display: flex; justify-content: space-around; align-items: flex-end;
  /* ⚠ THE SURFACE AND THE BORDER ARE NOT DECLARED HERE — see the ::after block
     below. They moved onto a masked layer so that masking the bar's EDGE cannot
     also erase the bar's CHILDREN, which is exactly what shipped in v66. The
     11px top padding stands in for the 1px border that used to be on this box,
     so the bar's height is unchanged by the move. */
  background: transparent; border-top: 0;
  padding: 11px 8px calc(8px + var(--sab));
  padding-left: calc(8px + var(--sal)); padding-right: calc(8px + var(--sar));
  font-size: 11px; color: var(--faint); position: relative;

  /* ── Q55, THIRD REPORT — ONE SOURCE FOR WHERE THE TWO CURVES MEET ──
     Owner, 2026-08-08, with a screenshot: "share button does not match with the
     borders accurately". Earlier the same day: "No accurate against panel
     curved border and too intense color."

     MEASURED FIRST, because two attempts had already been built from the
     description. At 375×667 and 430×932 the FAB's centre rendered 4.00px BELOW
     the bar's top edge (top:-24px on a 56px circle), while the mask's hole was
     centred ON that edge at `50% 0`. Two circles 4px out of concentric, so the
     ring of bar around the button was:

         above 10.00px · sides 5.76px · below 2.00px

     The comment on the mask had said "6px of clear air all round" since the
     notch shipped. That was the INTENT and the geometry never delivered it —
     and 2px at the bottom, under a shadow offset 10px DOWNWARD, is exactly the
     dark crescent in the photograph. It is a geometry defect, not a colour or a
     radius, which is why two colour-and-radius attempts did not touch it.

     THE FIX IS THAT NOTHING BELOW IS TYPED TWICE. The rise equals the radius,
     so the button's centre lands ON the bar's edge and the hole's centre needs
     no offset; the hole is the radius plus one ring. Change --fab-ring and the
     notch, the halo and the button all move together, which is the property the
     two hand-tuned numbers never had. */
  --fab-r: 28px;                              /* the button's radius */
  --fab-ring: 6px;                            /* clear air, all round, one value */
  --fab-rise: var(--fab-r);                   /* centre on the bar's top edge */
  --fab-hole: calc(var(--fab-r) + var(--fab-ring));
  /* The FAB overflows this bar UPWARD (top:-24px) into #app's area, and
     `.passport` carries z-index:1 — so on a short viewport the card painted
     over the button. Measured at 375x667: card bottom 597, FAB top 586, and
     elementFromPoint at the FAB's top-centre returned `passport`. Reported
     from a real device, where Safari's toolbar shortens the viewport enough to
     make the overlap reachable on a Pro Max too.
     The bar and its button must outrank screen content: they are chrome, and
     the FAB is the primary action. */
  z-index: 5;
}
.tabbar .tab {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  background: none; border: 0; padding: 0; font: inherit;
  color: inherit; cursor: pointer;
}
.tabbar .tab.on { color: var(--text); }
.tabbar .tab:disabled { cursor: default; }
.tabbar .tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
.tabbar svg { width: 22px; height: 22px; }
/* ── Q55 — the button sits IN the bar, not on it ──────────────
   Owner, 2026-08-07, with a reference: the bar's top edge dips in a curve
   around the central button, the circle is filled with the accent rather than
   with ink, the glyph is white, and a soft halo bleeds into the bar around the
   notch.

   ⚠ THE SHAPE WAS ASKED FOR, NOT THE FUNCTION. The reference's button is a QR
   glyph labelled Scan. This one stays the paper plane labelled Share: Q13 made
   it the ONE entrance to the share hub after the QR chip and the FAB both led
   there and both went dead for four days because neither was obviously the way
   in. Changing what it does is a different item.

   ── THE NOTCH IS A MASK, NOT AN SVG, AND HERE IS WHY ──
   The two honest options were an SVG path as the bar's background, or a mask on
   the bar element. The mask wins on the one property that matters here: it is
   RELATIVE. `at 50% 0` follows the bar's own centre at every width and through
   every safe-area inset, so the notch and the button cannot drift apart — and
   they must not, because share-fab-label.test.mjs taps the WORD and requires
   the FAB to be what is hit. An SVG path would need its viewBox re-fitted per
   width, and the drift would be silent. The cost is that the mask also clips
   the bar's top BORDER across the notch, which is correct: the border is the
   bar's edge, and the edge is what dips.

   ⚠ THE RADII ARE NO LONGER "TIED" BY A COMMENT ASKING THE NEXT PERSON TO KEEP
   THEM IN STEP. That sentence stood here while the two circles were 4px out of
   concentric and the ring ran 10 / 5.76 / 2px — a comment is not a mechanism.
   Both now read --fab-r, --fab-ring and --fab-rise off .tabbar. */
.fab {
  position: absolute; left: 50%; transform: translateX(-50%);
  top: calc(-1 * var(--fab-rise));
  width: calc(var(--fab-r) * 2); height: calc(var(--fab-r) * 2);
  border-radius: 50%;
  /* THE APP ACCENT, NEVER --profile. This is shell chrome shared by every
     screen; binding it to the per-card accent would make the app's primary
     control change colour with whichever card is on top, and fail the contrast
     gate outright on a pale one. */
  background: var(--accent);
  display: grid; place-items: center; border: 0; padding: 0; cursor: pointer;
  box-shadow: 0 10px 22px -8px var(--fab-shadow);
}
/* White on the accent, and legible_accent_text's arithmetic is what says so —
   the accent against the surface is 7.6:1, comfortably past AA. If the accent
   ever moves, that gate is what will refuse it. */
.fab svg { width: 24px; height: 24px; color: var(--surface); }
.fab:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* The bar's own edge dips around the button. 34px hole against a 28px circle.

   ⚠ THE MASK GOES ON A LAYER, NEVER ON THE BAR ITSELF, and v66 is why this is
   written in capitals. A mask applies to the element's ENTIRE SUBTREE, not to
   its background — so masking .tabbar erased the .fab inside it. The whole
   button vanished: the FAB is a 28px circle centred 4px below the hole's
   centre, so all of it falls within the 34px cut. Only the word Share survived,
   because it hangs 35px down and just clears the hole. Shipped, live, and
   photographed by the owner.

   NOTHING IN THE SUITE SAW IT. getComputedStyle still reported the accent
   background — masking does not change computed style. elementFromPoint still
   returned the FAB — masking affects painting, not hit-testing, the same trap
   that ate two earlier versions of the notch check, entered this time from the
   other side. And the pixel check sampled the bar's border row 31px off centre,
   which is outside the button, so it measured a real notch beside a button that
   was not there. A check that the FAB PAINTS is the one that was missing; it is
   in share-fab-label.test.mjs now, and it goes red on this exact regression.

   So the surface and the border live on this layer, the mask lives with them,
   and the bar's children are above it and untouched. z-index -2 puts it behind
   the halo (-1), which is behind the tabs and the FAB. */
.tabbar::after {
  content: ''; position: absolute; inset: 0; z-index: -2;
  background: var(--surface);
  border-top: 1px solid var(--border);
  pointer-events: none;
  /* CONCENTRIC WITH THE BUTTON BY CONSTRUCTION. The hole's centre is `50% 0`
     because --fab-rise makes that the button's centre too; the radius is the
     button's plus one ring. Neither number is written here.

     ⚠ IF var() EVER FAILS THE DECLARATION IS DROPPED AND THE BAR IS UNMASKED —
     a flat bar with a button on it, which is the state this item is fixing and
     not a broken screen. That is the right direction to fail in; a mask that
     resolved to a wrong radius would cut the button instead. */
  -webkit-mask-image: radial-gradient(circle var(--fab-hole) at 50% 0,
                      transparent 0 var(--fab-hole),
                      var(--ink) calc(var(--fab-hole) + 1px));
  mask-image: radial-gradient(circle var(--fab-hole) at 50% 0,
              transparent 0 var(--fab-hole),
              var(--ink) calc(var(--fab-hole) + 1px));
}

/* The halo. Behind the button, in front of nothing — it must not take a tap, so
   pointer-events are off and share-fab-label.test.mjs would catch it if they
   were not: that suite taps at the label's centre and names whatever it hits.

   ⚠ IT MAY OVERFLOW UPWARD BUT NEVER DOWNWARD. #nav is an in-flow flex child at
   the foot of the shell column, so anything hanging BELOW it extends the
   document's scrollable area — and this shell must not scroll at all. Drawn
   first as a 132×132 circle at top:-24px, it hung 52px past the bar and made
   the whole page scroll by that much; settings.test.mjs caught it by the
   number, "scrollHeight 720 vs client 667". Overflow above the bar is free
   (the FAB has lived at top:-24px all along), so the box grows upward instead
   and stops exactly at the bar's own bottom edge.

   That means no border-radius to clip a square box into a circle — the box is
   no longer square. The gradient is given an explicit 66px radius instead, so
   it reaches full transparency exactly at the box's left and right edges and
   well inside its top. It is cut at the BOTTOM, which is the bar's bottom, which
   is the bottom of the screen: there is nothing there to see the cut.

   ⚠ AND IT WAS NOT CENTRED ON THE BUTTON, which is the second half of the
   third report. `at 50% 91px` inside a box starting 60px above the bar put the
   halo's centre 31px BELOW the bar's top edge — 27px below the button it is
   drawn behind. Measured on the strip: at 30px to the RIGHT of the button the
   pixel read rgb(219,218,250), and 30px BELOW it read rgb(199,195,248). A glow
   that is stronger under a button than beside it does not read as a glow, it
   reads as a shadow that has slipped. It now takes its centre from the same
   --fab-rise as everything else. */
.tabbar::before {
  content: ''; position: absolute; left: 50%; top: -60px;
  transform: translateX(-50%);
  width: 132px; height: calc(100% + 60px);
  background: radial-gradient(circle 66px at 50% calc(60px - var(--fab-rise) + var(--fab-r)),
              var(--fab-halo) 0%, transparent 100%);
  pointer-events: none; z-index: -1;
}

/* ── the FAB's label (Q13) ──
   The plane is now the ONLY way into the share hub, so it has to say what it
   is. The reference draws this as `.tab-fab .lb` — 10.5px, weight 700 — inside
   an IN-FLOW flex item that holds the fab and the label together.

   ⚠ IT CANNOT BE IN FLOW HERE, and the reason is measured rather than
   aesthetic. This build's .fab is absolutely positioned at left:50% of #nav,
   while the bar's five slots are laid out with `space-around` over tabs of
   unequal width (Cards 29.3, Scanner 41.0, Contacts 43.4, Settings 39.8). That
   arrangement is not symmetric, so the middle SLOT's centre is not the bar's
   centre: measured 181.06 vs the FAB's 187.50 at 375, and 208.56 vs 215.00 at
   430. A label in the flow slot would sit 6.4px left of the plane above it, at
   both widths. In the reference the two agree only because the fab is in that
   slot too.

   So the label hangs off the FAB itself — same containing block, same left:50%,
   therefore centred under it by construction. Being inside the <button> also
   means the word is part of the FAB's hit target rather than a dead patch of
   text beside it. `top:100%` + 3px puts its box exactly where the other four
   labels sit (their 12px line, bottom-aligned by the bar's align-items:
   flex-end), and because it is out of flow it cannot move them or the bar's
   height — tests/viewport/share-fab-label.test.mjs asserts that by measuring
   the bar with and without it.

   ⚠ ITS DROP IS MEASURED FROM THE BAR, NOT FROM THE BUTTON, and that is new in
   the third Q55 pass. The button now rises --fab-rise (28px) instead of 24, so
   an offset of "3px under the button" would have carried the word 4px up with
   it and left it sitting above the other four labels. The word has to stay on
   THEIR line — it is the fifth item in a row of five — so the distance is
   expressed from the bar's own top edge and the button's height is subtracted
   back out. 35px is where the other four labels' boxes start, measured at both
   widths; share-fab-label.test.mjs compares the five directly, so a change to
   the bar's padding fails a test rather than drifting silently. */
.fab .fab-lb {
  position: absolute; left: 50%; top: 100%; transform: translateX(-50%);
  margin-top: calc(35px - (var(--fab-r) * 2 - var(--fab-rise)));
  font-size: 10.5px; font-weight: 700; line-height: 12px;
  /* NOT `inherit` — the parent is a <button>, which does not inherit the page
     font, so the label would come out in the UA's button face beside four tabs
     in Inter. .tabbar .tab escapes this with `font: inherit`; .fab holds only
     an <svg> and never needed to, so the family is named here instead. */
  /* Q55 — the label takes the button's colour, as the reference does. It is
     accent-on-surface TEXT, so legible_accent_text governs it, and the pair
     measures 7.6:1. This is the one place the accent is allowed to be ink,
     and only because that number clears AA with room. */
  font-family: var(--font-body); color: var(--accent); white-space: nowrap;
}

/* ── Passport card (adaptive) ── */
.passport {
  position: relative; z-index: 1; display: flex; flex-direction: column;
  width: min(86vw, 380px); height: var(--slide-h);
  /* The card is 80dvh, so it only fits if the chrome above and below it stays
     under 20dvh. On a 667pt screen the controls row + tab bar come to 23%, so
     the reference's fixed height overruns the lane. Bounding the card by the
     lane keeps the reference's sizing wherever there is room and shrinks it
     only where there isn't — 534px becomes 527px at 375×667, unchanged at
     430×932, which is exactly the spec's "card fills its lane".

     ── Q38, 2026-08-06: THE 600px CAP IS GONE, and `max-height` is why it can
     be. Owner, with the band above the card ringed: "the space looks empty now
     - calibrate."

     MEASURED on the shipped build, and the finding is that it was wrong at ONE
     size only:

       375×667   card 527px in a 547px lane — band 20px, 3.0% of the shell
       430×932   card 600px in an 812px lane — band 212px, 22.7% OF THE SCREEN

     `min(80dvh, 600px)` was two rules, and only one of them scaled. At 667pt
     80dvh is 534px and the cap never bound — the card was sized by the screen,
     correctly. At 932pt 80dvh is 746px, THE CAP BOUND, and the card stopped
     growing while the lane kept going. 600 was a number fitted to a 667pt
     phone; it does not mean anything on a taller one.

     Deleting it is safe precisely because of `max-height: 100%` below, which is
     what the paragraph above is really about: the lane, not the literal, is
     what stops the card overrunning. The cap was belt-and-braces that became a
     ceiling. Measured after: 430×932 band 66px (7.1%), 375×667 UNCHANGED at
     527px and 20px — that end was already right and is the end that breaks
     first, so it is asserted rather than hoped for.

     The card gets longer, not just bigger: ratio 0.616 → 0.496 at 430×932. That
     is deliberate and it follows the owner's own Q26 ruling — direction B, the
     cover grows into spare room — and the cover does: 72.3% → 77.7% of the
     card, with dead space inside still zero. Width is untouched, so Q4's drag
     distance and peek offset are untouched with it.

     Four candidates were measured before this one was chosen; a cap of 720px
     only reached 9.9% and `height: 100%` pinned the card to its lane at 2.1%
     but left it no margin at all. See docs/reports/2026-08-06-q38-calibrate.md. */
  max-height: 100%;
  border-radius: var(--r-lg); padding: clamp(12px, 3.5vw, 15px);
  background: linear-gradient(158deg, var(--stock1), var(--stock2));
  color: var(--text);
  box-shadow: 0 2px 4px rgba(20, 20, 40, .06),
              0 28px 54px -20px rgba(30, 27, 90, .4),
              0 0 0 1px rgba(0, 0, 0, .04);
}

/* frame = indigo backdrop (interchangeable solid/gradient) */
/* Q16 — THE COLOUR COMES FROM THE CARD. This drew
   linear-gradient(var(--accent), var(--accent2)), the GLOBAL brand indigo,
   while the preview drew var(--profile), the card's OWN accent, plus its cover
   photo. Same card, two colours, and changing the accent moved one of them.
   --profile/-lt/-dk are set per tile by core/theme.js's profileVars(); the
   fallbacks keep the old look for anything that renders without them (the
   empty and unavailable states draw a .pt-frame with no card behind it). */
/* ── Q26: THE COVER TAKES THE SLACK, not the middle of the panel ──
   Owner, 2026-08-06, from a screenshot with the empty region ringed: "card
   space is used poorly", then direction B from the proposal — "the cover
   grows".

   MEASURED BEFORE: the card is a FIXED height (min(80dvh, 600px)) and .pt-info
   was `flex: 1 1 auto` with the actions pinned at its foot, so the panel
   stretched and every spare pixel landed in the MIDDLE of it — 108.6px at
   375x667 and 93.6px at 430x932, 20.6% and 15.6% of the whole card, holding
   nothing. What was designed to fill it is .pt-stats, and that row is empty
   because nothing writes card.metrics (Q21, blocked on ops-iam-roles). So the
   gap was the shape of a feature that has not arrived.

   The fix is one line of flex, reversed: the FRAME grows and the PANEL is its
   content. The clamp stays as the frame's BASIS rather than its size, so a
   short viewport still gets the same floor it always had. Nothing about the
   card's height, the track, or the peek offset changes — Q4 settled those and
   this is entirely vertical, inside the card. */
.pt-frame {
  position: relative; flex: 1 1 clamp(210px, 40dvh, 340px);
  border-radius: 13px; overflow: hidden; display: grid; place-items: center;
  background: var(--profile, var(--accent));
  box-shadow: inset 0 0 0 1px var(--edge), 0 2px 6px rgba(30, 25, 15, .14);
}
/* The cover sits UNDER the tint, which is the preview's arrangement: the tint
   is a full-strength gradient with no cover and drops to .58 with one, so the
   photograph reads through in the card's own colour rather than beside it. */
.pt-cover {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: 0;
}
.pt-tint {
  position: absolute; inset: 0; z-index: 1; transition: opacity .2s;
  background: linear-gradient(135deg,
              var(--profile-lt, var(--accent)) 0%,
              var(--profile, var(--accent)) 52%,
              var(--profile-dk, var(--accent2)) 100%);
}
.pt-frame.has-cover .pt-tint { opacity: .58; }
.pt-frame::after {
  content: ''; position: absolute; inset: 0;
  background: radial-gradient(120% 85% at 80% -15%, rgba(255, 255, 255, .26), transparent 55%);
  pointer-events: none;
  /* Level with .pt-tint, and after it in tree order, so the rim light still
     reads over a cover photo. Without a z-index it is `auto` and the tint's
     z-index:1 paints straight over it — the same reason public-card gives its
     .hero::before an explicit 1. The medallion (3) and the two chips (4) are
     already above both. */
  z-index: 1;
}
/* (.pt-qr — the white QR chip at top-left — removed in Q13. The passport now
   carries one chip, the ⋯ menu, and the single share entrance is the labelled
   plane in the tab bar.) */
.pt-menu {
  position: absolute; top: 12px; right: 12px; width: 36px; height: 36px;
  border-radius: 50%; background: rgba(255, 255, 255, .9);
  display: grid; place-items: center; color: var(--on-card-ink);
  box-shadow: 0 2px 8px rgba(0, 0, 0, .2); z-index: 4;
  border: 0; padding: 0; cursor: pointer;
}
.pt-menu svg { width: 17px; height: 17px; }
.pt-menu:focus-visible { outline: 2px solid var(--on-card-ink-inv); outline-offset: 2px; }

/* ── Q20 — the card's options popover ─────────────────────────
   Drawn by design-system/products/meetback/proposals/q20-card-options.html,
   published to a preview channel and approved by the owner on 2026-08-07
   ("Q20 design is ok"). Values below are that drawing's, at build scale.

   ⚠ IT SITS ABOVE THE MEDALLION AND THE COVER. The frame stacks a tint (1),
   the cover (0), the ⋯ chip (4) and the medallion (3); a popover at anything
   below 5 is a menu with a photograph through it. `overflow` on .pt-frame is
   what would clip it, so this is deliberately INSIDE the frame's own box —
   top:52px keeps it clear of the 36px chip and its 12px inset.

   NOT A THIRD POPOVER IDIOM. The item requires it to follow the editor's item
   sheet — a row, a glyph, a label, a divider — rather than invent one. The
   sizes differ because that sheet is full width and this is 168px. */
.pt-pop {
  position: absolute; top: 52px; right: 12px; width: 168px; z-index: 6;
  background: var(--surface);
  border-radius: 13px;
  box-shadow: 0 14px 34px -10px var(--sheet-shadow);
  overflow: hidden;
}
.pt-pop[hidden] { display: none; }

/* ⚠ THE OPEN CARD RISES ABOVE THE ONES BEHIND IT, AND WITHOUT THIS THE POPOVER
   IS UNCLICKABLE. `.pt-pop` carries z-index 6, but `.passport` is
   `position: relative; z-index: 1` and therefore its own stacking context — so
   that 6 only orders things INSIDE the card. Between slides the order is tree
   order, and a later slide paints over an earlier one by construction, which is
   what makes a card "pass" the one above it. The popover hangs below its own
   card's top and straight under the next card.

   ⚠ THIS DID NOT START WITH Q64; Q64 MADE IT REACH. The popover sits at
   top:52px and runs about 150px, so it ended around 200px from the card's top
   while the stride was 166 — already overlapping by roughly a third, on a
   control most people open near the front card where there is nothing behind
   it. At an 18dvh stride (120px on a 375×667) the overlap covers most of it,
   and Playwright reported the truth plainly: "passport … from div data-i=1
   intercepts pointer events" for a click on card 0's Delete.

   One declaration, scoped to the state: only a slide with an OPEN popover
   changes its order, so the stack's normal painting is untouched. */
.track > .slide:has(.pt-pop:not([hidden])) { position: relative; z-index: 3; }
.pt-pop .row {
  display: flex; align-items: center; gap: 9px;
  width: 100%; padding: 11px 12px;
  background: none; border: 0; border-bottom: 1px solid var(--border);
  font-family: var(--font-body); font-size: 13px; font-weight: 650;
  color: var(--text); text-align: left; cursor: pointer;
}
.pt-pop .row:last-child { border-bottom: 0; }
.pt-pop .row .g { display: grid; place-items: center; flex: 0 0 auto; }
.pt-pop .row .g svg { width: 16px; height: 16px; }
.pt-pop .row.del { color: var(--err-strong); }
.pt-pop .row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ARMED. The colour is the smaller half of this state — the LABEL is what
   changes, and the owner's ruling is explicit that a red row still reading
   "Delete" is the state being rejected. The text wraps because "Delete this
   card permanently" does not fit 168px on one line, and truncating it would
   put the confirmation's meaning behind an ellipsis. */
.pt-pop .row.armed {
  background: var(--err-strong); color: var(--surface);
  font-weight: 800; line-height: 1.25;
  white-space: normal;
}

/* The chip stays lit while its menu is open, so which card the popover belongs
   to is visible on a carousel where two more cards are on screen. */
.pt-menu.lit {
  background: var(--surface);
  box-shadow: 0 0 0 3px var(--fab-halo), 0 2px 8px var(--sheet-shadow);
}

/* seal-ring medallion */
.pt-medal { position: relative; width: clamp(112px, 30vw, 134px); aspect-ratio: 1; z-index: 3; }
.pt-avatar {
  width: 100%; height: 100%; border-radius: 50%; overflow: hidden; background: var(--surface);
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px rgba(139, 147, 165, .6), 0 6px 16px rgba(20, 16, 60, .35);
}
.pt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pt-avatar .mono {
  width: 100%; height: 100%; display: grid; place-items: center;
  background: linear-gradient(160deg, var(--mono-1), var(--mono-2));
  font-family: var(--font-display); font-weight: 600;
  font-size: clamp(30px, 8vw, 38px); color: var(--accent);
}
.pt-badge {
  position: absolute; right: -3px; bottom: -3px;
  width: clamp(36px, 10vw, 42px); aspect-ratio: 1; border-radius: 50%;
  background: radial-gradient(circle at 38% 32%, var(--slate-400), var(--slate-500) 62%, var(--slate-600));
  display: grid; place-items: center; color: var(--on-card-ink-inv);
  box-shadow: 0 2px 5px rgba(40, 44, 58, .5),
              inset 0 1px 2px rgba(255, 255, 255, .4),
              inset 0 -2px 5px rgba(20, 24, 38, .5);
  z-index: 4;
}
.pt-badge svg { width: 56%; height: 56%; }

/* info */
/* `0 0 auto`, not `1 1 auto` — see the note at .pt-frame. The panel is now
   exactly as tall as what is in it, which is also why `margin-top: auto` on
   .pt-actions has nothing left to push against and quietly stops mattering.
   Leave it there: it becomes load-bearing again the moment .pt-stats lights up
   (Q21), and re-deriving it then is how a fix gets undone by its own success. */
.pt-info { flex: 0 0 auto; display: flex; flex-direction: column; padding-top: clamp(13px, 3.5vw, 16px); }
.pt-eyebrow {
  font-size: 9px; font-weight: 600; letter-spacing: .22em; text-transform: uppercase;
  color: var(--seal); text-align: center; margin-bottom: 4px;
}
.pt-label {
  font-family: var(--font-display); font-optical-sizing: auto; font-weight: 600;
  font-size: clamp(23px, 6vw, 27px); letter-spacing: -.02em;
  text-align: center; line-height: 1; color: var(--text);
}
/* Q23 — the job title and company, under the private label.
   Deliberately quiet against .pt-label above it: the label is what tells two
   cards apart in the carousel and must stay the thing the eye lands on, so
   this is the UI face at 12-13px rather than the display face at 23-27px.
   Only rendered when there is something to put in it — see passportMarkup. */
.pt-role {
  font-size: clamp(12px, 3.2vw, 13px); line-height: 1.35; text-align: center;
  color: var(--muted); margin-top: 5px;
  /* A long title and a long company on one line is the ordinary case, not the
     edge: "Senior Solutions Architect · Northgate Laboratories" is 52
     characters against a tile that is min(86vw, 380px) wide. Two lines are
     allowed and a third is not — past that the line is competing with the
     card, and the tile has a fixed height it must not push. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
.pt-stats { display: flex; margin-top: clamp(14px, 4vw, 18px); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.pt-stat { flex: 1; text-align: center; padding: clamp(10px, 3vw, 13px) 0; }
.pt-stat + .pt-stat { border-left: 1px solid var(--border); }
.pt-stat .n { font-weight: 700; font-size: clamp(18px, 5vw, 21px); color: var(--text); line-height: 1; }
.pt-stat .l { font-size: 11px; color: var(--muted); margin-top: 4px; }
.pt-actions { display: flex; gap: 8px; margin-top: auto; padding-top: clamp(13px, 3.5vw, 16px); }
.pt-btn {
  cursor: pointer; font-family: var(--font-body); font-weight: 600;
  font-size: clamp(13px, 3.6vw, 14px); border-radius: 12px;
  padding: clamp(11px, 3.2vw, 13px);
  display: flex; align-items: center; justify-content: center; gap: 6px;
  border: 1px solid var(--border); transition: .15s;
}
.pt-btn svg { width: 18px; height: 18px; }
.pt-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pt-btn.p { flex: 1; background: var(--accent); border-color: var(--accent); color: var(--surface); box-shadow: 0 8px 18px -8px rgba(79, 70, 229, .7); }
.pt-btn.p:hover { background: var(--accent-dk); }
.pt-btn.e { flex: 1; background: transparent; color: var(--text); }
.pt-btn.e:hover { background: rgba(0, 0, 0, .04); }
.pt-btn.i { flex: 0 0 auto; width: clamp(46px, 13vw, 52px); padding: 0; background: transparent; color: var(--accent); }
.pt-btn.i:hover { background: rgba(79, 70, 229, .08); }
.pt-btn[aria-disabled="true"] { opacity: .45; cursor: default; }
.pt-btn[aria-disabled="true"]:hover { background: transparent; }

/* ── First-run and signed-out states ──────────────────────────────
   Not in the pinned reference (it renders the populated case only), but both
   are required by build request §7. Built strictly from the reference's own
   vocabulary — the same passport, frame, medallion and button classes — so no
   new visual language is introduced. See STATUS.md: a designed reference for
   the signed-out screen is still outstanding. */
.pt-note {
  font-size: var(--t-sm); color: var(--muted); text-align: center;
  line-height: 1.45; margin-top: clamp(8px, 2.5vw, 10px);
  padding: 0 clamp(4px, 2vw, 10px);
}

/* Sign-in carries two stacked actions and, once revealed, two fields — more
   than the passport's info block holds at 375×667 against a 40dvh backdrop.
   The backdrop is the part with slack (it is holding one medallion), so it
   gives up the room. Nothing else about the tile changes. */
.passport.auth .pt-frame { flex: 0 0 clamp(150px, 26dvh, 220px); }
/* `display:flex` on .pt-form beats the `hidden` attribute's UA rule, which
   left the sign-in fields on screen permanently. Global, because every screen
   after this one will reach for `hidden` and hit the same thing. */
[hidden] { display: none !important; }

.pt-form { display: flex; flex-direction: column; gap: 8px; margin-top: clamp(10px, 3vw, 14px); }
.pt-field {
  font-family: var(--font-body); font-size: var(--t-sm);
  padding: clamp(10px, 3vw, 12px); border-radius: 12px;
  border: 1px solid var(--border); background: var(--surface); color: var(--text);
  width: 100%;
}
.pt-field:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.pt-err { font-size: var(--t-xs); color: var(--err); text-align: center; margin-top: 8px; }
.pt-err:empty { display: none; }

@media (max-height: 640px) {
  .passport { height: auto; }
  .pt-frame { flex-basis: clamp(160px, 30dvh, 220px); }
  .pt-medal { width: clamp(96px, 24vw, 112px); }
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }

