/* ============================================================================
   app.css — layout and components. OWNED BY IT.

   Designers change tokens.css, not this file. Every value below resolves
   through a token; there are no literal colours or sizes here.

   STATUS: M0. Page shell only — no component styling yet. Components arrive
   with their pages in M2, and are restyled from real tokens in M7.
   ========================================================================= */

*, *::before, *::after { box-sizing: border-box; }

/* The delivered display face, Latin subset. swap: titles render in the
   fallback stack for the instant before the 18 KB arrives, rather than
   holding the page blank. */
@font-face {
  font-family: 'Agressive';
  src: url('../assets/fonts/agressive-subset.woff2') format('woff2');
  font-display: swap;
}

/* Body and label faces, shipped with the site rather than borrowed from the
   phone. The old stack named Segoe UI, -apple-system and Helvetica Neue, none
   of which exist on Android — so it fell through to whatever that make calls
   its default, and the same page came out in Roboto on a Pixel, One UI Sans on
   a Samsung and MiSans on a Xiaomi.

   Both are variable fonts: one file each covers every weight. Latin only, at
   47 KB and 32 KB; Chinese still falls to the system CJK stack, exactly as the
   display face already does. SIL Open Font License, texts alongside them. */
@font-face {
  font-family: 'Inter';
  src: url('../assets/fonts/inter-latin.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: 'Roboto Mono Web';
  src: url('../assets/fonts/roboto-mono-latin.woff2') format('woff2');
  font-weight: 100 700;
  font-display: swap;
}

html {
  background: var(--space);
  /* Both spellings. This stops the browser inflating text on its own in narrow
     columns — it does NOT, and should not, override a reader who has chosen a
     larger text size for themselves. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  min-height: 100dvh;
  background: var(--space);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--size-body);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
}

:lang(zh) { font-family: var(--font-cjk); line-height: var(--leading-cjk); }

img, svg { max-width: 100%; height: auto; display: block; }

/* Interactive elements must always show keyboard focus. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Every tappable thing clears the WCAG minimum, whatever it looks like. */
button, a[role="button"], .tappable {
  min-width: var(--tap-min);
  min-height: var(--tap-min);
}

main:focus { outline: none; }

/* Available to assistive tech, invisible on screen. */
.vh {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ---------------------------------------------------------------- no-JS ---- */

.noscript {
  padding: var(--s-5) var(--edge);
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  color: var(--text);
}
.noscript p { margin: 0; max-width: 60ch; }

/* -------------------------------------------------------- error banner ----- */
/* Shown by core.js when content fails to load or parse. Loud on purpose:      */
/* a blank page during the event is the failure we are designing against.      */

.fault {
  margin: var(--s-4) var(--edge);
  padding: var(--s-4);
  border: 1px solid var(--state-alert);
  border-left-width: 3px;
  border-radius: 0 var(--r-control) var(--r-control) 0;
  background: var(--panel);
  color: var(--text);
}
.fault h2 {
  margin: 0 0 var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--size-small);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--state-alert);
}
.fault p { margin: 0 0 var(--s-2); }
.fault p:last-child { margin-bottom: 0; }
.fault code {
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  color: var(--text-mid);
  word-break: break-all;
}

/* ------------------------------------------------------------- masthead --- */
/* Brand identity. Scrolls away with the page — the controls are what need to
   persist, not the wordmark.                                                */

/* The masthead sits on the header band drawn into the background image, so
   everything here is width-proportional and survives artwork swaps.

   Geometry was reviewed and approved against the round 1 background, then
   re-verified against the round 2 file (1081px-wide source): the band there
   ends at row 177 — 0.1637 of the width against round 1's 0.1647 — and the
   corner clusters reach only ~14% in from each side, so the approved logo
   box gains clearance. The ratio below is kept from that review; only the
   bitmap changed. */
.masthead {
  display: flex;
  justify-content: center;
  align-items: center;
  /* The header graphic's own proportions, measured off the file: it ends on
     its stepped line at row 137 of 860. Matching it exactly is what lets the
     control bar below sit in the sky between that line and the bright
     hairline, rather than across it. */
  aspect-ratio: 860 / 137;
}
.masthead img { width: 38%; height: auto; }

/* --------------------------------------------------------------- topbar --- */
/* Masthead and control bar, held still while the page scrolls under them.

   This reverses "the masthead scrolls away with the page, it is identity, not
   a control" in page-layouts.md. What that reasoning missed is that the two
   pieces are one graphic: the band is the top of the starfield and the bar
   sits in the sky beneath it, so scrolling one and pinning the other tore the
   artwork in half and left the controls over open sky.

   It also fixes a real defect. The bar had no scrim, on the reasoning that
   "the controls are delivered artwork with their own fills, so they stay
   legible over whatever scrolls behind them" — but those fills are 15%
   opaque, so session titles scrolled straight through the buttons on every
   room page. Painting the band here puts something solid behind them.

   It costs about 116px at 375px wide — a seventh of the screen, permanently.
   That is the price of the controls never being more than a tap away, which
   is the call page-layouts.md already made for the bar alone.

   Wrapped in JS at boot rather than in the templates; see mountChrome().  */
.topbar {
  position: sticky;
  top: 0;
  z-index: 30;
  /* The band, from the same file and the same anchor .device paints it with,
     so the seam is invisible: this is the top of that image, held still. */
  background: url('../assets/brand/bg-space.webp') top center / 100% auto no-repeat,
              var(--bg-tail);
}

/* Home draws the framed artwork instead of the plain starfield, so the topbar
   matches the frame rather than the field. Band and gap are exactly the
   masthead's aspect and the bar's height, which is what .page-frame already
   relies on, so the two line up without a second set of numbers. */
body[data-page="home"] .topbar {
  background: url('../assets/brand/bg-band.webp')
                top center / 100% calc(100% - var(--bar-h)) no-repeat,
              url('../assets/brand/bg-gap.webp')
                bottom center / 100% var(--bar-h) no-repeat,
              var(--bg-tail);
}

/* ------------------------------------------------------------ control bar -- */

/* No backdrop blur here. It looked good and cost a full-width re-blur on
   every frame of every scroll and every transition — on the one element that
   is on screen 100% of the time. Solid is faster and the difference is small.
   */

/* Exactly --tap-min tall, no vertical padding: .page-frame-gap is given the
   same height, which is what lands the artwork's bright hairline on this
   bar's bottom edge instead of through the language button. */
.bar {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  height: var(--bar-h);
  padding: 0 var(--edge);
}
.bar-spacer { flex: 1; }

/* The delivered artwork. Its fill is cyan at 15% alpha, so it renders as
   dark glass over the page ground — the earlier "pale variant" reading was
   this file composited over a white preview, not the file itself. Width
   follows the 152x119 ratio at the 44px tap height. */
.bar .back {
  flex: none;
  width: 56px;
  height: var(--tap-min);
  padding: 0;
  /* A real <button>: the UA border has to go explicitly now that this control
     no longer passes through the drawn-frame block that stripped it. */
  border: 0;
  background: url('../assets/brand/back-button.png') center / 100% 100% no-repeat;
  cursor: pointer;
}
.bar .back span { display: none; }   /* drawn chevron; the artwork has its own */
.bar .back:active { transform: scale(.94); }

/* Explicit and labelled. An icon on its own is not obvious enough for the one
   control people reach for when they are lost. */
/* Delivered artwork, same translucent glass as the back button: the fill is
   cyan at low alpha, so it reads dark over the handbook and warms by itself
   over the Zone 2 gradient — which is why the old Zone 2 recolour override
   is gone. The label stays live text: "Home" translates, and the language
   chip shows the current locale code. */
.bar .home {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  /* 192x65 at the 44px tap height, the same rule .back and .lang follow.
     Sized to its label instead it came out 79px and squashed the delivered
     artwork by 39% horizontally, pulling its corners out of shape. Every
     language's word fits inside this with room to spare. */
  width: 130px;
  height: var(--tap-min);
  justify-content: center;
  padding: 0;
  border: 0;
  background: url('../assets/brand/btn-home.png') center / 100% 100% no-repeat;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  letter-spacing: .08em;
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
}
.bar .home:active { transform: scale(.94); }
.bar .home img { flex: none; height: 15px; width: auto; }

.bar .lang {
  flex: none;
  /* Fixed to the artwork's own 76x61 ratio at the 44px tap height,
     the same bargain .back makes. Sizing it to the label instead gave a
     44px square, which squashed a button drawn a quarter wider than tall. */
  width: 55px;
  height: var(--tap-min);
  padding: 0;
  border: 0;
  background: url('../assets/brand/btn-lang.png') center / 100% 100% no-repeat;
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  letter-spacing: .06em;
  cursor: pointer;
}
.bar .lang:active { transform: scale(.94); }

/* -------------------------------------------------- language sheet -------- */

.sheet {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: flex-end;
  background: rgba(5, 11, 22, .7);
  padding: var(--s-4);
}

.sheet-panel {
  width: 100%;
  max-width: var(--w-max);
  margin: 0 auto;
  padding: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--panel);
  box-shadow: var(--lift-raised);
}

.sheet-title {
  margin: 0 0 var(--s-1);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-low);
  font-weight: 600;
}

.sheet-opt {
  min-height: var(--tap-min);
  padding: var(--s-3) var(--s-4);
  text-align: left;
  border: 1px solid var(--line);
  border-radius: var(--r-control);
  background: var(--panel-glass);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: var(--size-option);
  cursor: pointer;
}
.sheet-opt.on {
  border-color: var(--accent-line);
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 600;
}
/* State is never signalled by colour alone. */
.sheet-opt.on::after {
  content: " ✓";
  font-family: var(--font-mono);
}

.sheet-close {
  min-height: var(--tap-min);
  margin-top: var(--s-1);
  border: 0;
  border-radius: var(--r-control);
  background: none;
  color: var(--text-mid);
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  cursor: pointer;
}

/* ============================================================= components ==
   Every page is capped at --w-max and centred, so the layout is identical on
   a 430px phone and a desktop browser used for review.
   ========================================================================= */

/* --------------------------------------------------------------- device ---
   The handbook is a phone app — attendees reach it by tapping a name tag, and
   the supported range is 320-430px (NFR-9). There is one layout, capped at
   --w-max.

   On a laptop that column would otherwise float in dead space and read as
   broken. Above 760px it is presented in a frame instead, so the page fills
   the screen and looks deliberate. This is presentation only: no second
   layout, nothing extra to design, translate-check or test.               */

.device {
  position: relative;   /* containing block — an absolute child must not escape */
  max-width: var(--w-max);
  margin: 0 auto;
  min-height: 100dvh;
  /* The plain starfield, as every page but home has always had it — over a
     seamless star tile that carries on below it.

     The artwork is a fixed 1:1.78 and a page is any height at all, so on its
     own it stopped partway down and handed over to flat colour on a hard
     horizontal edge. Its bottom 18% now dissolves into the tile instead, and
     the tile repeats for as long as the page runs.

     The tile is drawn at 50% width because the artwork is 860px shown at
     100%: half scale is what makes the two starfields the same star size. */
  background: url('../assets/brand/bg-space.webp') top center / 100% auto no-repeat,
              url('../assets/brand/bg-stars.webp') top center / 50% auto repeat,
              var(--bg-tail);
}
/* Home draws the framed artwork instead, in .page-frame below. */
body[data-page="home"] .device { background: var(--bg-tail); }

/* A shooting star leaves the column at the end of its pass, and an off-screen
   box is still a wider page — NFR-9 says no horizontal scroll at any width,
   and opacity: 0 does not excuse a box from counting. So the x axis is
   clipped, at the edge of the screen and nowhere short of it.

   Nowhere short of it matters. Clipping at the constellation's own box was
   tried first and put the cut 11px inside the frame's bright rail, where a
   star lost its head to a straight vertical line in open space. Out here the
   boundary is the edge of the phone, so the same cut reads as a star leaving.

   x only, and clip rather than hidden: clip is not a scroll container, so the
   app bar keeps sticking to the viewport — the reason the desktop rule below
   uses it too. overflow-y is left alone rather than set to visible, so that
   rule can still clip both axes to its rounded frame. */
body[data-page="home"] .device { overflow-x: clip; }

/* ----------------------------------------------------------- page frame ---
   HOME ONLY. The round 2 artwork is a frame: header band, a bright hairline,
   side rails, and a closing hairline at the foot. Every other page keeps the
   plain starfield it already had.

   Cut into four, because a frame has to reach the bottom of whatever screen
   it is on and one image at `100% auto` is only ever right at one aspect.
   `cover` crops the rails off; `100% 100%` stretches the header band.

     band  the header graphic, locked to its own aspect so it never distorts
     gap   plain sky closing on the bright hairline. Exactly the height of the
           control bar, which is what puts that hairline on the bar's bottom
           edge at every width instead of across the language button
     mid   the rail run, stretching to whatever height is left
     foot  the rails closing, the bottom hairline, and the band beneath it

   Named page-frame, not frame: .frame is already the event page's panel.   */

.page-frame { display: none; }

body[data-page="home"] .page-frame {
  position: absolute;
  inset: 0;
  z-index: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  pointer-events: none;
}
.page-frame > * { flex: none; }
.page-frame-band {
  aspect-ratio: 860 / 137;
  background: url('../assets/brand/bg-band.webp') top center / 100% 100% no-repeat;
}
.page-frame-gap {
  height: var(--bar-h);   /* == the control bar, so the hairline lands on it */
  background: url('../assets/brand/bg-gap.webp') bottom center / 100% 100% no-repeat;
}
.page-frame-mid {
  flex: 1 1 auto;
  min-height: 0;
  background: url('../assets/brand/bg-mid.webp') top center / 100% 100% no-repeat;
}
.page-frame-foot {
  aspect-ratio: 860 / 184;
  background: url('../assets/brand/bg-foot.webp') bottom center / 100% 100% no-repeat;
}
/* Everything else sits above the frame. */
#chrome, #announce, #app { position: relative; z-index: 1; }
#app { padding: 0 var(--edge) var(--s-7); }

/* 520px = --w-max plus comfortable breathing room. Below this the column is
   close enough to full width that a frame would look cramped. */
@media (min-width: 520px) {
  body {
    padding: var(--s-6) var(--s-4);
    /* Ambient ground, so the framed column reads as raised off the page. */
    background:
      radial-gradient(90% 55% at 50% 0%, rgba(62, 198, 240, .07), transparent 70%),
      var(--ambient);
  }
  .device {
    min-height: calc(100dvh - var(--s-7));
    border: 1px solid var(--line);
    border-radius: 28px;
    /* No background here: the shorthand would wipe the starfield painted by
       the base rule, image and all. */
    box-shadow: 0 30px 80px rgba(0, 0, 0, .5);
    /* clip, not hidden: overflow:hidden makes this a scroll container and the
       sticky app bar would stop sticking to the viewport. */
    overflow: clip;
  }
  .noscript { max-width: var(--w-max); margin: 0 auto var(--s-4); border-radius: var(--r-card); }

  /* Stick to the top of the framed column, not the browser window. `top: 0`
     pins to the viewport, which on this layout is the body's padding above
     .device — so the header tore loose from the frame and floated over the
     ambient ground. Offsetting by that padding lands it on the frame's own
     top edge, and .device's overflow: clip takes its square corners off.

     The shadow fills the padding strip above it. .device scrolls with the
     page, so once it has travelled up, that strip is inside the frame and
     showed a sliver of content sliding past over the header. */
  .topbar {
    top: var(--s-6);
    box-shadow: 0 calc(-1 * var(--s-6)) 0 var(--ambient);
  }
}

/* Default room accent. Room and topic pages override it inline from JSON. */
.roomwrap { --room: var(--accent); }

/* ------------------------------------------------------------ page head --- */

.phead { padding: var(--s-2) 0 var(--s-4); }
/* Round 2: titles set in the display face, uppercase like the delivered
   wordmark, washed light-to-dark with the designer's gradient pair. The
   solid accent stays underneath as the painted colour, so anything that
   cannot clip a background to text still shows the right cyan. */
.phead h1 {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--size-title);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--accent);
  background: linear-gradient(180deg, var(--accent-light), var(--accent));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  display: flex;
  align-items: center;
  gap: var(--s-2);
}
.phead-sub {
  margin: var(--s-1) 0 0;
  font-size: var(--size-small);
  color: var(--text);
}
.rdot {
  flex: none;
  width: 9px;
  height: 9px;
  border-radius: var(--r-pill);
  background: var(--room);
}

.shead {
  margin: var(--s-5) 0 var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-low);
  font-weight: 600;
}

/* ----------------------------------------------------------------- tabs --- */

.tabs { display: flex; gap: var(--s-2); margin-bottom: var(--s-3); }
.tab {
  flex: 1;
  min-width: 0;
  min-height: var(--tap-min);
  padding: 0 var(--s-2);
  border: 1px solid var(--line);
  border-radius: var(--r-control);
  background: var(--panel-glass);
  color: var(--text-mid);
  font-family: var(--font-mono);
  font-size: var(--size-small);
  letter-spacing: .04em;
  cursor: pointer;
}
.tab.on {
  border-color: var(--accent-line);
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 600;
}

/* --------------------------------------------------------- session rows --- */

.srow {
  display: flex;
  gap: var(--s-3);
  align-items: flex-start;
  padding: var(--s-3);
  margin-bottom: var(--s-2);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--panel-glass);
  text-decoration: none;
  color: var(--text);
}
.srow:active { background: var(--accent-soft); border-color: var(--accent-line); }

.srow-time {
  flex: none;
  width: 38px;
  padding-top: 2px;
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  font-variant-numeric: tabular-nums;
  color: var(--text-low);
}
.srow-main { flex: 1; min-width: 0; }
/* Titles wrap in full — a half-read session title is useless (FR-5.3). */
.srow-main b {
  display: block;
  font-size: var(--size-row);
  font-weight: 600;
  line-height: 1.35;
}
.srow-main i {
  display: block;
  margin-top: 2px;
  font-style: normal;
  font-size: var(--size-caption);
  color: var(--text-mid);
}
.srow-go { flex: none; padding-top: 2px; color: var(--text-low); }

/* ------------------------------------------------------------- hub cards -- */

/* Card gap is a section gap, not a row gap — the composition gives each
   card room, and the white outlines below need clearance to read. */
.hub { display: flex; flex-direction: column; gap: var(--s-5); }
/* The delivered card artwork, its baked English erased in place — the fill
   behind the words is one flat colour, so the patch is invisible and the
   words stay live text in three languages. One cleaned file serves all three
   cards; its own chevron, plates and white outline replace the whole drawn
   set. Stretched to the box: a wrapped Malay line makes the card taller than
   the artwork's aspect and the frame stretches with it, which holds up well
   short of a designer-supplied tall variant.

   Padding mirrors the artwork's own geometry: text begins 7% in from the
   left, and the right side clears the baked chevron zone. */
/* Title on one row, description on the next. Side by side, the description
   was competing with the title for a share of about 156px at 320px wide, and
   losing: "Parking and hotel." came out as three words on three lines. Given
   the full width it fits on one line, or two in the longer languages. */
.hub-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: var(--s-1);
  min-height: 68px;
  padding: var(--s-3) 56px var(--s-3) 28px;
  background: url('../assets/brand/card-blank.png') center / 100% 100% no-repeat;
  text-decoration: none;
  color: var(--text);
}
.hub-card:active { filter: brightness(1.3); }   /* instant, no motion */
.hub-card b {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--size-row);
  font-weight: 700;
  letter-spacing: .03em;
  color: var(--accent);
}
.hub-card span { font-size: var(--size-caption); color: var(--text); }

/* ------------------------------------------------------- content frame --- */
/* The bracketed panel the cards sit inside. Same two-layer frame as the
   cards, one size up, so the cut stays a family resemblance rather than a
   second idea. */

/* The delivered panel artwork, stretched to the box. Its interior alpha is
   what lets the starfield show through, and its top ticks are part of the
   file — the drawn .frame-ticks span is gone. Stretch distortion between
   the panel's 0.68 aspect and the box's ~0.75 lands on the 3px strokes and
   is below what an eye picks up.

   Pulled out toward the screen edge: #app already pads to --edge, and a
   second inset on top of that cost the cards 32px — enough to push the
   longest Malay heading onto a third line at 320px. */
.frame {
  position: relative;
  margin: 0 calc(var(--s-2) - var(--edge));
  /* The composition holds the cards well clear of the panel border — the gap
     is part of the look, not slack. Malay still wraps within this at 320px;
     it costs card height, not overflow. */
  padding: var(--s-7) var(--s-7) var(--s-6);
  min-height: 58vh;
  background: url('../assets/brand/frame-panel.png') center / 100% 100% no-repeat;
}

/* --------------------------------------------------------- agenda blocks -- */
/* The agenda's room blocks wear design round 8 and are styled with the other
   designed pages, at the end of this file. */

/* ------------------------------------------------------------------ map --- */

.floorplan {
  margin-bottom: var(--s-3);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--panel-glass);
  overflow: hidden;
}
.floorplan img { width: 100%; }
.floorplan-missing {
  padding: var(--s-7) var(--s-4);
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--size-caption);
  color: var(--text-low);
}

.places { list-style: none; margin: 0; padding: 0; }
.places li {
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--line);
}
.places li:last-child { border-bottom: 0; }
.places b { display: block; font-size: var(--size-row); }
.places span { font-size: var(--size-caption); color: var(--text-mid); }

/* ---------------------------------------------------------------- cards --- */

.card {
  padding: var(--s-3) var(--s-4);
  margin-bottom: var(--s-2);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  background: var(--panel-glass);
}

.prose p { margin: 0 0 var(--s-2); color: var(--text-mid); font-size: var(--size-small); }
.prose p:last-child { margin-bottom: 0; }
.prose h2 {
  margin: var(--s-5) 0 var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--size-row);
  letter-spacing: .02em;
}
.muted { color: var(--text-low); }
.empty {
  padding: var(--s-5) 0;
  text-align: center;
  font-size: var(--size-small);
  color: var(--text-low);
}

.topic-title {
  margin: 0 0 var(--s-3);
  font-family: var(--font-mono);
  font-size: var(--size-title);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: -.01em;
}

.chips { display: flex; flex-wrap: wrap; gap: var(--s-1); margin-bottom: var(--s-2); }
.chip {
  padding: 4px var(--s-2);
  border: 1px solid var(--line);
  border-radius: var(--r-chip);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--text-mid);
}
.chip.room { border-color: var(--room); color: var(--room); }

/* -------------------------------------------------------------- speaker --- */

.sp { display: flex; gap: var(--s-3); align-items: center; }
.sp-photo, .sp-mono {
  flex: none;
  width: 52px;
  height: 52px;
  border-radius: var(--r-card);
  object-fit: cover;
}
/* Monogram is the expected path for a meaningful share of speakers, not an
   edge case. A broken image must never render (FR-6.3). */
.sp-mono {
  display: grid;
  place-items: center;
  border: 1px solid var(--room);
  background: var(--panel);
  color: var(--room);
  font-family: var(--font-mono);
  font-size: 17px;
  font-weight: 700;
}
.sp-body { min-width: 0; }
.sp-body b { display: block; font-size: var(--size-row); }
.sp-body i {
  display: block;
  font-style: normal;
  font-size: var(--size-caption);
  color: var(--text-mid);
}

/* ------------------------------------------------------------ resources --- */

.res {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  min-height: var(--tap-min);
  padding: var(--s-3) var(--s-4);
  margin-bottom: var(--s-2);
  border-radius: var(--r-card);
  font-family: var(--font-mono);
  font-size: var(--size-small);
  text-decoration: none;
}
.res.on {
  border: 1px solid var(--accent-line);
  background: var(--accent-soft);
  color: var(--accent);
  font-weight: 600;
}
.res.on:active { background: var(--panel); }
/* Dashed says "this is a thing, but not yet" without ever offering a control
   that fails when tapped. */
.res.off {
  border: 1px dashed var(--line-strong);
  background: var(--panel-glass);
  color: var(--text-low);
}
.res-label { min-width: 0; }
.res-meta { flex: none; font-size: var(--size-label); opacity: .8; }
.res-note {
  flex: none;
  max-width: 48%;
  text-align: right;
  font-family: var(--font-sans);
  font-size: var(--size-label);
  line-height: 1.35;
}

/* --------------------------------------------------------- language pick ---
   First visit only, and home only. It takes the constellation's place; the
   masthead, the control bar and the date strip stay exactly as they are.

   The drop includes an Overlay.png: 1081x1921 of flat cyan at 25% alpha. It is
   not used. Laid over this background it turns the whole screen teal, and the
   mockup shows no tint at all — it reads as an artboard layer that was not
   visible in the composite. Kept in design/round-2/ in case that is wrong.

   Spacing is in margins, not gap. A percentage `gap` in a column flex box
   resolves against a height that is itself defined by the content, so it
   collapses to zero and the heading lands on the first plate. Percentage
   MARGINS resolve against width, which is what every other measurement on
   this page already scales with.                                           */

.lang-pick {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding-bottom: 8%;
}
.lang-pick:focus { outline: none; }
.lang-head,
.lang-tip { margin: 0; }
.lang-head { width: 72%; }
.lang-tip  { width: 38%; margin-top: 1.6%; }

.lang-opt {
  width: 88%;
  margin-top: 4.5%;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.lang-opt:first-of-type { margin-top: 9%; }
.lang-opt:active { filter: brightness(1.18); }
.lang-opt:focus-visible { outline: none; }
.lang-opt:focus-visible img { outline: 2px solid var(--text); outline-offset: 3px; }

.lang-head img,
.lang-tip img,
.lang-opt img { width: 100%; height: auto; }

/* ------------------------------------------------------------ constellation */
/* Earth is the control; six planets orbit it. Everything here is a percentage
   of the stage, so the arrangement scales 320-430px without collisions.

   Positions are POLAR. Each .node is a zero-height arm as wide as the stage,
   pivoting on the orbit centre; --r is a percentage translation along that
   arm, and a percentage translation resolves against the element's own width,
   which is the stage's. That is the whole trick, and it is why this needs no
   container query units: those would fail hard where unsupported rather than
   degrade, and a homepage that renders every planet in a pile on someone's
   phone is not a trade worth making for tidier syntax.

   The inner counter-rotation cancels the arm's, so a planet travels along its
   arc without tumbling and its label stays upright the whole way out.        */

.constel {
  position: relative;
  width: 100%;
  /* Sized to what the orbits actually occupy. Slightly taller than wide,
     because a hexagon with planets at the horizontal is widest across and the
     captions add height below the lower two. #app centres what is left. */
  aspect-ratio: 375 / 400;
  --orbit-y: 48%;   /* where Earth's centre sits; every arm pivots on it */
}

/* Shooting stars. Absolutely placed in the stage, so they scale and travel
   with the arrangement rather than sitting at fixed pixels beside it. First in
   the DOM, which puts them under .orbit — the mockup runs one tail behind
   Event Page. Decorative: empty alt, and never a tap target. */
.streak {
  position: absolute;
  height: auto;
  pointer-events: none;
  /* Negative by two fifths of the cycle, which is the mid-point of the flight
     window below: the page opens with every star already on its mark, so the
     first thing anyone sees is the frame the designer drew, and the movement
     starts from there rather than from an empty sky. */
  animation: streak-fly var(--fly) linear calc(var(--fly) * -0.4) infinite;
}

/* The flight fills the first four fifths of the cycle; the rest is the star
   parked off the end at zero opacity, which is how a keyframe list gets a gap
   after it - there is no animation-gap. One list for all three, with only
   --fly changing, so a longer cycle buys a slower crossing AND a longer wait,
   and the three keep different speeds without three sets of keyframes.

   Four fifths rather than a third is what makes the sky continuous. The share
   is what decides how much of the time a star is on screen, and with three of
   them the odds of all three being dark at once fall off fast: at a third it
   happened for four seconds at a stretch, at four fifths the longest silence
   anywhere on the page is 0.96s, measured over ten minutes of cycles. Pushing
   further closes the last second but turns each path into a conveyor - the
   same star retracing the same line every 1.7s - so this is the far end of
   what these three fixed paths can give.

   Both ends are translate(x, y) - a matching function list, so it interpolates
   componentwise with no @property registration to lean on.

   Quick to arrive, slow to leave, which is the shape of the real thing: full
   brightness by a fifth of the way across, dimming from three fifths.

   It is dark by 85% of the pass, and that number is measured rather than
   chosen. The clip above cuts at the stage edge, and a shooting star wears
   its head at the leading end - so a star still lit when it reached that edge
   lost its brightest pixels to a straight vertical line. The lowest star runs
   closest to the rail and crosses it first, at 85% of its pass; every star
   goes out before then. The last 15% is travel nobody sees, and it is worth
   it: without it the crossing has to be shortened instead. */
@keyframes streak-fly {
  0%    { opacity: 0; transform: translate(var(--fx), var(--fy)); }
  16%   { opacity: 1; }
  48%   { opacity: 1; }
  68%   { opacity: 0; }
  80%,
  100%  { opacity: 0; transform: translate(calc(var(--fx) * -1), calc(var(--fy) * -1)); }
}

/* Clipped because the arms are stage-width boxes swung out past the edge:
   their overflow is invisible but would otherwise be scrollable, and NFR-9
   says no horizontal scroll at any width. No planet reaches this boundary —
   the furthest right edge lands at 89% — so nothing real is cut. */
.orbit {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.node {
  position: absolute;
  left: 0;
  top: var(--orbit-y);
  width: 100%;
  height: 0;
  transform-origin: 50% 0;
  transform: rotate(var(--a)) translateX(var(--r)) rotate(calc(-1 * var(--a)));
  text-decoration: none;
}

/* Pinned to the arm's pivot, then pulled back by half its own box. The sphere
   sits dead centre of every delivered render — measured, all seven within
   0.5% — so centring the box centres the sphere. */
.node-body {
  position: absolute;
  left: 50%;
  top: 0;
  width: var(--w);
  transform: translate(-50%, -50%);
}
.node-ball { width: 100%; height: auto; }

/* Below the sphere, never on it: white on the pale planets measured 2.9:1.
   Out of flow, so .node-body's height stays the image's and the arithmetic
   above keeps centring the sphere rather than the sphere-plus-caption. */
.node-label {
  position: absolute;
  left: 50%;
  /* Hung off the SPHERE, not the image box. Saturn's box is its rings, and a
     caption dropped below those sits 3% of the stage lower than one dropped
     below the ball — which is the difference between clearing Earth's arc text
     and running into it. The mockup hangs them off the ball too.
     --sw is the sphere's share of the box width, --ar the box's height over
     its width, so sw/ar is the sphere's half-height in box-height units. */
  top: calc(50% + 50% * var(--sw, 1) / var(--ar, 1));
  margin-top: 2px;
  transform: translateX(-50%);
  font-family: var(--font-display);
  /* Smaller than the shared label size: these sit under artwork rather than
     over a surface, and every pixel of caption height on the upper two
     planets is a pixel taken off Earth's arc text below them.

     Proportional, not fixed. Everything else here scales with the stage, so a
     fixed caption is largest relative to the artwork at 320px — which made
     that width, and only that width, decide how big Earth and the planets
     were allowed to be. Below 520px the device IS the viewport, so vw tracks
     the stage; the clamp holds it steady once the device stops growing. */
  font-size: clamp(7.4px, 2.3vw, 9px);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text);
  line-height: 1.15;
  text-shadow: 0 1px 6px var(--space);
  /* Wraps against the SPHERE, not the image box. Saturn's box is twice its
     sphere because of the rings, so a caption allowed to fill it reaches out
     under Earth's arc text. Half again the sphere's width is enough for one
     room name and forces "Planet Test" onto the two lines the delivered
     artwork sets it in anyway. Wider than this and BR4's caption reaches the
     stage edge once the orbits widen. */
  width: max-content;
  max-width: calc(var(--sw, 1) * 115%);
  text-align: center;
}

.node:active .node-body { filter: brightness(1.15); }
.node:focus-visible { outline: none; }
.node:focus-visible .node-ball { outline: 2px solid var(--text); outline-offset: 4px; border-radius: var(--r-pill); }

/* ------------------------------------------------------------------ earth -- */

.earth {
  position: absolute;
  left: 50%;
  top: var(--orbit-y);
  width: var(--earth-w, 31%);   /* set by home(); see EARTH_W in js/pages.js */
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transform: translate(-50%, -50%) scale(var(--earth-scale, 1));
  transition: transform var(--earth-t, 0ms) var(--orbit-ease);
}
.earth-ball { width: 100%; height: auto; }
.earth:focus-visible { outline: none; }
.earth:focus-visible .earth-ball { outline: 2px solid var(--text); outline-offset: 6px; border-radius: var(--r-pill); }

/* The wording curving across Earth's face. Two arcs, cross-faded, because
   swapping one element's text mid-transition flickers. Both are decorative —
   the button's name is the visually-hidden span, which tracks whichever of
   these is showing. */
.arc {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  fill: var(--accent);
  font-family: var(--font-display);
  /* viewBox units, not pixels — so this scales with Earth and reads the same
     at every screen size. A shade larger than the delivered bitmap's, which
     carried the English string across 95 degrees: here it covers about 96, and those
     lower ends are what clear the captions on the two planets above. Chinese
     is four glyphs and simply covers less of the same arc, which is the point
     of setting it live rather than shipping the picture. */
  font-size: 4.6px;
  letter-spacing: .02em;
  text-transform: uppercase;
  transition: opacity 200ms linear;
}
.arc-close {
  opacity: 0;
  /* Rides a sphere less than half the closed one's size, and sits in the ring
     below it rather than on it, so it is set larger in viewBox units to come
     out the same on screen. */
  font-size: 10px;
}

.constel[data-state="open"]    .arc-open,
.constel[data-state="opening"] .arc-open  { opacity: 0; }
.constel[data-state="open"]    .arc-close,
.constel[data-state="opening"] .arc-close { opacity: 1; }

/* --------------------------------------------------------------- states -- */

.constel[data-state="closed"] .orbit { visibility: hidden; }

.constel[data-state="closed"] .node {
  transform: rotate(calc(var(--a) - 105deg)) translateX(0) rotate(calc(-1 * (var(--a) - 105deg)));
}
.constel[data-state="closed"] .node-body {
  opacity: 0;
  transform: translate(-50%, -50%) scale(.28);
}

/* Nothing is tappable while it is moving. A link under a travelling finger
   is a mis-tap waiting to happen. */
.constel[data-state="opening"] .orbit,
.constel[data-state="closing"] .orbit { pointer-events: none; }

/* Closed, Earth is the whole screen. 3.423x takes its sphere from 18.69% of
   the stage to the same 64% it has always been — the closed state is fixed,
   so this multiplier moves every time the open size does. This multiplier has to move
   whenever Earth's open size does - they are one object, not two. */
.constel[data-state="closed"]  .earth,
.constel[data-state="closing"] .earth { --earth-scale: 3.423; }
.constel[data-state="opening"] .earth { --earth-t: var(--orbit-out); }
.constel[data-state="closing"] .earth { --earth-t: var(--orbit-in); }

.constel[data-state="opening"] .node,
.constel[data-state="closing"] .node {
  animation: orbit-arm var(--orbit-dur) var(--orbit-ease) both;
  animation-delay: calc(var(--i) * var(--orbit-stag));
}
.constel[data-state="opening"] .node-body,
.constel[data-state="closing"] .node-body {
  animation: orbit-ball var(--orbit-dur) var(--orbit-ease) both;
  animation-delay: calc(var(--i) * var(--orbit-stag));
}
.constel[data-state="opening"] { --orbit-dur: var(--orbit-out); --orbit-stag: var(--orbit-stagger); }
.constel[data-state="closing"] { --orbit-dur: var(--orbit-in);  --orbit-stag: var(--orbit-stagger-in); }
.constel[data-state="closing"] .node,
.constel[data-state="closing"] .node-body { animation-direction: reverse; }

/* The arm swings 105 degrees into its resting angle while the radius grows
   from nothing: a spiral out of Earth rather than a straight shot. Both
   function lists match end to end, so this interpolates componentwise with
   no @property registration and stays on the compositor. */
@keyframes orbit-arm {
  from { transform: rotate(calc(var(--a) - 105deg)) translateX(0) rotate(calc(-1 * (var(--a) - 105deg))); }
  to   { transform: rotate(var(--a)) translateX(var(--r)) rotate(calc(-1 * var(--a))); }
}
@keyframes orbit-ball {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(.28); }
  45%  { opacity: 1; }
  100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

/* The one place motion is allowed, and it still asks first. home() reads the
   same query and skips straight to the open state, so this is belt and
   braces rather than the only guard. */
@media (prefers-reduced-motion: reduce) {
  .constel .node,
  .constel .node-body { animation: none; }
  /* Stars stop where the mockup put them, which is exactly the page as it
     shipped before they flew. Nothing is lost by sitting this one out. */
  .streak { animation: none; }
  .earth { transition: none; }
  .arc { transition: none; }
}

/* ------------------------------------------------------------ event foot -- */
/* Delivered artwork. It replaced the hard-coded date/wifi/help-desk strip —
   see decisions.md for what that cost and why it was accepted. */

/* Pushed to the foot of the screen and set inside the band the artwork
   leaves below its closing hairline — 0.1326 x width, measured off the file.
   Percentage padding resolves against WIDTH, which is the same thing the
   frame's aspect ratios resolve against, so the two stay locked together at
   every screen size. */
/* The closing band the artwork leaves below its bottom hairline: 184 rows of
   860, taken from the same slice the frame's foot is cut from. Centring in
   that box puts the wording midway between the line and the screen edge at
   every width. */
.event-foot {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 860 / 184;
  padding: 0 var(--edge);
}
.event-foot img { width: 66%; height: auto; }

/* The welcome wording. Sits above the constellation, at the offset below the
   control bar the mockup gives it — a percentage, because percentage margins
   resolve against WIDTH, which is what the frame's proportions resolve against
   too, so the two stay locked together at every screen size. */
.home-title {
  display: flex;
  justify-content: center;
  /* 16% down, then 6% pulled back off the bottom. The two cancel in the flex
     column, so the wording drops without the constellation following it: the
     box still occupies the 10% it always did, the contents just sit lower in
     it. Plain margin would have taken the planets halfway down with it,
     because the constellation absorbs the slack through its auto margins. */
  margin: 16% 0 -6%;
}
.home-title img { width: 83%; height: auto; }

/* Home is one screen: the constellation takes the slack so the footer lands
   on the frame's closing band instead of wherever the content happens to stop. */
body[data-page="home"] .device { display: flex; flex-direction: column; }
/* The framed inset is for content that has to clear the side rails. The
   control bar sits above where those rails start, so it keeps the ordinary
   edge and lands in the same place on every page. */
body[data-page="home"] #app,
body[data-page="home"] #announce { --edge: var(--edge-framed); }
body[data-page="home"] #app {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  padding-bottom: 0;
}
/* The slack goes above and below the constellation, not all of it above the
   footer — which is what an auto margin on the footer alone would do. The
   footer then simply sits last, on the frame's closing band. */
body[data-page="home"] .constel { margin-block: auto; }

/* ============================================================== M4 · time ==
   Which session is running, which is next, which is done. All computed from
   the device clock — no server involved.
   ========================================================================= */

.srow.is-current {
  border-color: var(--accent-line);
  background: var(--accent-soft);
}
.srow.is-current .srow-time { color: var(--accent); }
/* Finished dims but stays tappable — the slides are still there. */
.srow.is-finished { opacity: .5; }

.flag-now, .flag-next {
  display: inline-flex;
  align-items: center;
  gap: var(--s-1);
  margin-bottom: var(--s-1);
  font-family: var(--font-mono);
  font-size: 8.5px;
  letter-spacing: .16em;
  text-transform: uppercase;
}
.flag-now { color: var(--accent); }
.flag-next { color: var(--text-low); }

.pulse {
  width: 6px;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
  flex: none;
}

.sim {
  margin: 0 0 var(--s-3);
  padding: var(--s-2) var(--s-3);
  border: 1px dashed var(--state-alert);
  border-radius: var(--r-control);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--state-alert);
}

/* ------------------------------------------------------------- row edge --- */

/* The near-white segments that interrupt a designed row's border. Every
   session row and speaker card renders one; the frames section draws it only
   where a page's artwork has them. */
.row-edge { display: none; }

/* -------------------------------------------------------- announcement ---- */

.announce {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  margin: var(--s-3) var(--edge) 0;
  padding: var(--s-3);
  border-radius: var(--r-control);
  border: 1px solid var(--line);
  background: var(--panel);
  font-size: var(--size-small);
}
.announce.urgent {
  border-color: var(--state-alert);
  border-left-width: 3px;
  color: var(--text);
}
.announce p { margin: 0; flex: 1; }
.announce-x {
  flex: none;
  width: 28px;
  min-width: 28px;
  height: 28px;
  min-height: 28px;
  border: 0;
  border-radius: var(--r-chip);
  background: none;
  color: var(--text-low);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}

/* ------------------------------------------------------- floorplan zoom --- */

.floorplan { position: relative; cursor: zoom-in; }
.floorplan-hint {
  position: absolute;
  right: var(--s-2);
  bottom: var(--s-2);
  padding: 3px var(--s-2);
  border-radius: var(--r-chip);
  background: rgba(5, 11, 22, .78);
  font-family: var(--font-mono);
  font-size: var(--size-label);
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--text-mid);
}
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 70;
  background: rgba(3, 8, 16, .96);
  overflow: auto;
  /* Pinch-zoom works because nothing here disables it. */
  -webkit-overflow-scrolling: touch;
}
.lightbox img { min-width: 100%; }
.lightbox-close {
  position: fixed;
  top: var(--s-4);
  right: var(--s-4);
  width: var(--tap-min);
  height: var(--tap-min);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-control);
  background: var(--panel);
  color: var(--text);
  font-size: 16px;
  cursor: pointer;
}

/* ============================================== zone 2 · the planet test ====
   DESIGN ROUND 6, delivered 8 September 2026.

   This section used to open "a deliberate tonal break": the ground warmed from
   navy to plum, the accent moved to the plushie palette, the result page
   flooded with the character's own colour. The artwork reverses all of it.

   Nothing in the drop is warm. Measured off the files, the start button, the
   progress bar, the trait chip and the divider are #4BBAEA and #A8DFF9 — the
   accent this site has used since round 2 — the start button's interior is the
   same wash round 5 measured off the info panels, to the byte, and every piece
   of display text is #E5E5E9, the near-white the room frames already use. No
   ground was delivered, and the mockups show the plain starfield every other
   page has. The only warm objects on the screen are the three plushies and the
   result's tagline.

   So Zone 2 is no longer a zone. It is a third scope wearing the same three
   layers as the rooms and the chrome pages — see decisions.md for what that
   cost, and design/round-6/README.md for what was measured.

   FOUR FRAMES DO NOT COME THROUGH separate-frames.py. The answer rows and the
   two result buttons are drawn with an opaque radial gradient inside them, and
   that glow is the entire difference between the chosen answer and the rest.
   Normalising it to a flat wash would throw the state away, so those four ship
   as delivered colour and CSS stretches them like any other frame. Nothing is
   recoloured; there is only one colour in the drop.
   ========================================================================= */

.quizwrap {
  --frame-fill: var(--frame-wash);
  --frame-ink: linear-gradient(180deg, var(--accent-light), var(--accent));
}

/* ------------------------------------------------------------- intro ------ */

.quiz-intro { text-align: center; padding-top: var(--s-4); }

/* The intro wears the Saturn from the home constellation, not a plushie. The
   old screen showed Nova, which quietly gave one of three outcomes top billing
   before a question had been asked. The mockup uses the planet, which is also
   what the home page's Planet Test orbit shows, so the door and the room
   behind it are the same object. */
.quiz-planet { display: block; width: 68%; margin: 0 auto var(--s-7); }

.quiz-intro h1 {
  margin: 0 0 var(--s-3);
  font-family: var(--font-display);
  font-size: var(--size-hero);
  font-weight: 400;
  line-height: 1.06;
  letter-spacing: .01em;
  text-transform: uppercase;
  color: var(--fr-edge);
}
.quiz-lede {
  margin: 0 auto var(--s-7);
  max-width: 34ch;
  font-size: var(--size-small);
  line-height: var(--leading-body);
  color: var(--fr-edge);
}

/* One word in every locale — MULA, 开始 — so it never wraps and can be held at
   the artwork's own proportion rather than sized by its content. */
.btn-play {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  aspect-ratio: 666 / 148;
  padding: 0;
  border: 0;
  background: none;
  color: var(--fr-edge);
  font-family: var(--font-display);
  font-size: var(--size-action);
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
  --m-fill: url('../assets/brand/quiz/btn-start.fill.webp');
  --m-stroke: url('../assets/brand/quiz/btn-start.stroke.webp');
}
.btn-play:active { --frame-fill: var(--fr-glow); }
.btn-play:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* ---------------------------------------------------------- progress ------ */

.prog { padding-top: var(--s-2); }
.prog-count {
  margin: 0 0 var(--s-2);
  font-family: var(--font-display);
  font-size: var(--size-caption);
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--accent);
}

/* The drop delivers this bar seven times, once per question answered, and each
   is the same hatch clipped further along. separate-frames.py splits the track
   from the hatch so the clip can happen here instead — which is what stops the
   artwork depending on there being exactly seven questions.

   The hatch is inset 16px of 864 at each end, so it starts at 1.852% and ends
   at 98.03%. --prog is the fraction answered, set by the controller. */
.prog-track {
  position: relative;
  aspect-ratio: 864 / 47;
  --frame-fill: transparent;
  --m-stroke: url('../assets/brand/quiz/bar-track.stroke.webp');
}
.prog-fill {
  position: absolute;
  inset: 0;
  background: var(--accent);
  -webkit-mask: url('../assets/brand/quiz/bar-fill.stroke.webp') center / 100% 100% no-repeat;
          mask: url('../assets/brand/quiz/bar-fill.stroke.webp') center / 100% 100% no-repeat;
  clip-path: inset(0 calc(98.148% - var(--prog) * 96.18%) 0 0);
}

/* -------------------------------------------------------- question -------- */

.qtext {
  margin: var(--s-5) 0 var(--s-4);
  font-size: var(--size-question);
  font-weight: 700;
  line-height: 1.32;
  letter-spacing: -.015em;
  color: var(--fr-edge);
  text-wrap: balance;
}

/* A container, so a row can be given the artwork's own proportion as a floor
   it is free to grow past. See .opt. */
.opts {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  container-type: inline-size;
}

/* Sized by its content, not by the artwork's 812x140. A Malay answer wraps to
   two lines where the mockup fits one — the drop's own mockup has one of the
   four wrapping already — so the row has to grow, and the frame stretches with
   it exactly as a session row does. The straight edges take that; there is no
   circle in this shape to go oval. */
.opt {
  display: flex;
  align-items: center;
  width: 100%;
  /* 140/812 of the column: the row's drawn proportion, as a floor it can grow
     past. One English answer in the drop's own mockup already wraps, and Malay
     wraps two of the four at 320px, so the row has to grow — and a fixed
     padding cannot both match the drawing at 375 and still match it at 430.

     aspect-ratio is the obvious way to write this and it is wrong here. A flex
     item with a ratio and a definite width takes that ratio as its *transferred
     size suggestion*, and its automatic minimum size is the smaller of that and
     its content — so the ratio wins, the row stays one line tall and the second
     line hangs out of the frame. A container query unit has no such clamp. */
  min-height: 17.241cqw;
  padding: var(--s-3) var(--s-5);
  border: 0;
  border-radius: 0;
  text-align: left;
  background: url('../assets/brand/quiz/row-answer.webp') center / 100% 100% no-repeat;
  color: var(--fr-edge);
  font-family: var(--font-sans);
  font-size: var(--size-option);
  line-height: 1.42;
  cursor: pointer;
}
.opt:active,
.opt.on {
  background-image: url('../assets/brand/quiz/row-answer-on.webp');
  font-weight: 600;
}
/* Selection is never signalled by colour alone. It is barely signalled at all
   in the ordinary run — choosing an answer is the navigation, so this state is
   what you come back to when you press back.

   The selected frame ships as delivered, which is dimmer than the one composed
   into the mockup: the empty file's interior is rgb(0,83,109) against
   rgb(0,114,141) in the filled one. Keeping the empty file is not only the
   round 5 rule about wordless artwork being canonical — it is the only one of
   the two that passes. Near-white on the mockup's brighter interior measures
   4.41:1, under the 4.5 floor; on this one it is 6.8:1. If the designer
   confirms the brighter reading was intended, the label has to go dark before
   the interior comes up. */
.opt.on::after {
  content: "✓";
  /* A flex item now, not a run of text: the two spaces that used to separate
     it from the answer collapse to nothing. */
  margin-left: var(--s-2);
  font-family: var(--font-mono);
}
.opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.qhint {
  margin-top: var(--s-4);
  text-align: center;
  font-family: var(--font-display);
  font-size: var(--size-caption);
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--accent);
}

/* ---------------------------------------------------------- plushie ------- */

/* Sized by height, because the three renders are not one shape: Nova is 0.85
   wide for its height and Terra 0.75, so a square box would show them at three
   different sizes. The ball behind it is the pre-load fallback and keeps its
   own square. */
.plushie {
  display: flex;
  justify-content: center;
  height: 208px;
  margin: 0 auto var(--s-4);
}
.plushie-ball {
  width: 208px;
  height: 208px;
  border-radius: var(--r-pill);
  background:
    radial-gradient(circle at 26% 20%, rgba(255, 255, 255, .42), transparent 34%),
    radial-gradient(circle at 32% 26%, color-mix(in srgb, var(--char) 55%, #fff), var(--char) 52%,
      color-mix(in srgb, var(--char) 45%, #000) 100%);
  box-shadow: var(--lift-glow) color-mix(in srgb, var(--char) 42%, transparent);
}
.plushie-photo { display: block; width: auto; height: 100%; }

/* ----------------------------------------------------------- result ------- */

.result { text-align: center; padding-top: var(--s-3); }
.r-name {
  margin: 0 0 var(--s-1);
  font-family: var(--font-display);
  font-size: var(--size-display);
  font-weight: 400;
  line-height: 1;
  letter-spacing: .01em;
  text-transform: uppercase;
  color: var(--fr-edge);
}
/* The one warm thing on the page that is not a plushie. Measured off the
   artwork at #DDB18E, which is nobody's character colour — Nova's is #F26A21.
   Whether it is meant to be fixed or to follow the character is open with the
   designer; the drop only draws Nova. */
.r-role {
  margin: 0 0 var(--s-4);
  font-size: var(--size-caption);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--quiz-tagline);
}
.r-desc {
  margin: 0 0 var(--s-3);
  font-size: var(--size-small);
  line-height: 1.6;
  color: var(--fr-edge);
}

/* The drop draws this chip three times, 176, 233 and 300px wide, for BOLD,
   CURIOUS and FAST-MOVING. The words are translated, so no fixed width serves,
   and stretching one pill flattens its caps — at 1.7x the stroke goes thin on
   the sides and thick top and bottom. It is also the only shape in the drop
   with nothing drawn into it: a plain stadium, one stroke, no chamfer, no
   ornament, transparent inside. So this is the one place round 6 is redrawn
   rather than masked, and border-radius reproduces it at any width. */
.traits {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-2);
  list-style: none;
  margin: var(--s-5) 0;
  padding: 0;
}
.traits li {
  padding: 11px var(--s-4);
  border: 1.5px solid var(--accent);
  border-radius: var(--r-pill);
  font-size: var(--size-caption);
  font-weight: 600;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--fr-edge);
}

/* Held at its own aspect: a line with a round dot at each end, and the dots go
   oval the moment it is not.

   It divided the character from the plushie disclaimer and the buttons. The
   disclaimer is gone — there are no plushies at the event — so it now divides
   the character from the buttons, which is the job it was doing anyway. */
.r-divider {
  width: 100%;
  aspect-ratio: 797 / 19;
  margin: var(--s-5) 0;
  --frame-fill: transparent;
  --frame-ink: var(--accent);
  --m-stroke: url('../assets/brand/quiz/divider.stroke.webp');
}

.r-btns { display: flex; gap: var(--s-2); }
.btn-ghost, .btn-solid {
  flex: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--s-2) var(--s-3);
  min-height: 68px;
  border: 0;
  border-radius: 0;
  background: center / 100% 100% no-repeat;
  color: var(--fr-edge);
  font-size: var(--size-caption);
  font-weight: 700;
  letter-spacing: .06em;
  line-height: 1.3;
  text-transform: uppercase;
  text-decoration: none;
}
/* Two drawings, not one drawing and a tint: the drop delivers the quiet button
   with a near-black interior and the loud one flooded with the accent. */
.btn-ghost { background-image: url('../assets/brand/quiz/btn-ghost.webp'); }
.btn-solid { background-image: url('../assets/brand/quiz/btn-solid.webp'); }

/* ----------------------------------------------------------------- misc --- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-min);
  padding: 0 var(--s-5);
  margin-top: var(--s-4);
  border-radius: var(--r-control);
  border: 1px solid var(--accent-line);
  background: var(--accent-soft);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: var(--size-small);
  letter-spacing: .06em;
  text-transform: uppercase;
  text-decoration: none;
}

/* ============================================================================
/* ============================================================================
/* ============================================================================
   Breakout rooms — design rounds 3 and 4. BR1 delivered 7 September, BR2-BR4
   on the 8th.

   Scoped to .roomwrap[data-room], which is the page wrapper room.html and
   topic.html render. Only a page wrapper opens this scope: a bare [data-room]
   would dress any element that happens to carry one, and until 11 September
   every row of the cross-room strip did.

   The agenda page borrows these rooms' palettes for its room blocks since
   round 8, but none of these shapes — see the end of this file.

   EVERY FRAME IS THE DELIVERED ARTWORK. Nothing here redraws a shape. An
   earlier pass rebuilt the rows, tabs and chips as clip-path polygons measured
   off the files, on the theory that artwork cannot stretch to fit content —
   and each drifted from the drop in a way that was invisible alone and obvious
   beside the original: chamfers at the wrong angle, edge segments missing, a
   badge that was a rounded rectangle instead of a notched plate. Measuring a
   shape and redrawing it is designing it again. The artwork is the design.

   tools/separate-frames.py splits each file into masks that carry shape only —
   fill, stroke, and for the rows a near-white accent layer. CSS paints them,
   so the geometry is the designer's to the pixel, the colour comes from the
   room, and the wording stays live text in three languages.

   WHAT STRETCHING COSTS. A row grows with its content: a Malay title wraps to
   two lines where the mockup fits one, and the mask stretches with it. Straight
   edges take that. Circles do not, so the time badge is lifted into its own
   square file by that tool and placed here at its own aspect. The speaker's
   ring is not lifted — it is the frame's left cap rather than an element
   inside it, and taking it out tore a gap in the outline on any taller row.
   ========================================================================= */

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

/* One mechanism for every framed element. Three layers, because the artwork
   has three: the interior wash, the border, and — on rows — the near-white
   segments that interrupt it. Two of those are ::before and ::after; the third
   is .row-edge, a real element, because an element's own background paints
   beneath both of its pseudo-elements and there is nowhere else to put it.

   Round 5's event map and other information pages use the same three layers,
   and round 6's Planet Test does too. Both are listed once as
   `.<scope> .framed` rather than element by element: the room pages name their
   elements semantically because room.html predates the artwork, but these were
   built for it and carry a marker class, so one selector does what nine do
   above. Neither has a .row-edge — no near-white segment interrupts a border
   outside the room and map rows.

   Round 8's agenda names its five in one :is() instead. Two of them, the day
   tabs and the session rows, come from renderers it shares with the room
   pages, and those carry no marker. */
.roomwrap[data-room] .panel,
.roomwrap[data-room] .srow,
.roomwrap[data-room] .sp,
.roomwrap[data-room] .tab,
.roomwrap[data-room] .chip,
.roomwrap[data-room] .res,
.roomwrap[data-room] .srow-time,
.roomwrap[data-room] .sp-ring,
.chromewrap .framed,
.quizwrap .framed,
.agendawrap :is(.tab, .rblock-head, .rblock-body, .srow, .srow-time) {
  position: relative;
  isolation: isolate;
  border: 0;
  border-radius: 0;
  background: none;
}

.roomwrap[data-room] .panel::before,
.roomwrap[data-room] .panel::after,
.roomwrap[data-room] .srow::before,
.roomwrap[data-room] .srow::after,
.roomwrap[data-room] .sp::before,
.roomwrap[data-room] .sp::after,
.roomwrap[data-room] .tab::before,
.roomwrap[data-room] .tab::after,
.roomwrap[data-room] .chip::before,
.roomwrap[data-room] .chip::after,
.roomwrap[data-room] .res::before,
.roomwrap[data-room] .res::after,
.roomwrap[data-room] .srow-time::before,
.roomwrap[data-room] .srow-time::after,
.roomwrap[data-room] .row-edge,
.chromewrap .framed::before,
.chromewrap .framed::after,
.quizwrap .framed::before,
.quizwrap .framed::after,
.agendawrap :is(.tab, .rblock-head, .rblock-body, .srow, .srow-time)::before,
.agendawrap :is(.tab, .rblock-head, .rblock-body, .srow, .srow-time)::after,
.chromewrap .row-edge {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
}

/* Interior, border, accents. */
.roomwrap[data-room] .panel::before,
.roomwrap[data-room] .srow::before,
.roomwrap[data-room] .sp::before,
.roomwrap[data-room] .tab::before,
.roomwrap[data-room] .chip::before,
.roomwrap[data-room] .res::before,
.roomwrap[data-room] .srow-time::before,
.chromewrap .framed::before,
.quizwrap .framed::before,
.agendawrap :is(.tab, .rblock-head, .rblock-body, .srow, .srow-time)::before {
  background: var(--frame-fill);
  -webkit-mask-image: var(--m-fill);
          mask-image: var(--m-fill);
}
.roomwrap[data-room] .panel::after,
.roomwrap[data-room] .srow::after,
.roomwrap[data-room] .sp::after,
.roomwrap[data-room] .tab::after,
.roomwrap[data-room] .chip::after,
.roomwrap[data-room] .res::after,
.roomwrap[data-room] .srow-time::after,
.chromewrap .framed::after,
.quizwrap .framed::after,
.agendawrap :is(.tab, .rblock-head, .rblock-body, .srow, .srow-time)::after {
  background: var(--frame-ink);
  -webkit-mask-image: var(--m-stroke);
          mask-image: var(--m-stroke);
}
.roomwrap[data-room] .row-edge,
.chromewrap .row-edge {
  display: block;
  background: var(--fr-edge);
  -webkit-mask-image: var(--m-hi);
          mask-image: var(--m-hi);
}

/* Rows sit on charcoal rather than the room's wash — the artwork paints them
   that way in every room. */
.roomwrap[data-room] .srow,
.roomwrap[data-room] .sp,
.roomwrap[data-room] .srow-time { --frame-fill: var(--fr-row-wash); }

/* ---------------------------------------------------------------- shape --- */

/* One set of shapes, worn by all four rooms; only the colour below differs.
   The drops delivered a full set per room, and they are near enough identical
   to be the same drawing — but not exactly, and the differences read as
   sloppiness rather than intent: BR1's session row is 728x140 against BR2's
   727x162, so the same list came out at two different row heights depending
   which room you were in. One set, four palettes.

   The set is BR1's, with one exception noted in design/round-4/README.md: the
   resources bar comes from BR2's drop, because BR1's never shipped an empty
   one and an erased frame is a worse starting point than a clean one. */
.roomwrap[data-room] .panel-day {
  --m-fill: url('../assets/brand/rooms/panel-day.fill.webp');
  --m-stroke: url('../assets/brand/rooms/panel-day.stroke.webp');
}
.roomwrap[data-room] .panel-speakers {
  --m-fill: url('../assets/brand/rooms/panel-speakers.fill.webp');
  --m-stroke: url('../assets/brand/rooms/panel-speakers.stroke.webp');
}
.roomwrap[data-room] .panel-summary {
  --m-fill: url('../assets/brand/rooms/panel-summary.fill.webp');
  --m-stroke: url('../assets/brand/rooms/panel-summary.stroke.webp');
}
.roomwrap[data-room] .srow {
  --m-fill: url('../assets/brand/rooms/row-session.fill.webp');
  --m-stroke: url('../assets/brand/rooms/row-session.stroke.webp');
  --m-hi: url('../assets/brand/rooms/row-session.hi.webp');
}
.roomwrap[data-room] .sp {
  --m-fill: url('../assets/brand/rooms/row-speaker.fill.webp');
  --m-stroke: url('../assets/brand/rooms/row-speaker.stroke.webp');
  --m-hi: url('../assets/brand/rooms/row-speaker.hi.webp');
}
.roomwrap[data-room] .tab {
  --m-fill: url('../assets/brand/rooms/tab-day.fill.webp');
  --m-stroke: url('../assets/brand/rooms/tab-day.stroke.webp');
}
.roomwrap[data-room] .chip.room {
  --m-fill: url('../assets/brand/rooms/chip-room.fill.webp');
  --m-stroke: url('../assets/brand/rooms/chip-room.stroke.webp');
}
.roomwrap[data-room] .chip:not(.room) {
  --m-fill: url('../assets/brand/rooms/chip-time.fill.webp');
  --m-stroke: url('../assets/brand/rooms/chip-time.stroke.webp');
}
.roomwrap[data-room] .res {
  --m-fill: url('../assets/brand/rooms/bar-resource.fill.webp');
  --m-stroke: url('../assets/brand/rooms/bar-resource.stroke.webp');
}
.roomwrap[data-room] .srow-time {
  --m-fill: url('../assets/brand/rooms/badge-time.fill.webp');
  --m-stroke: url('../assets/brand/rooms/badge-time.stroke.webp');
}

/* ---------------------------------------------------------------- colour -- */

/* The whole difference between the four rooms — on their own pages, and on
   the agenda's room blocks, whose round 8 artwork is drawn in these same
   gradients and washed with these same solids, to the byte. */
.roomwrap[data-room="br1"],
.agendawrap .rblock[data-room="br1"] {
  --ink-light: var(--br1-ink-light);
  --ink: var(--br1-ink);
  --solid: var(--br1-solid);
  --frame-fill: var(--br1-wash);
  --frame-ink: linear-gradient(180deg, var(--br1-ink-light), var(--br1-ink));
}
.roomwrap[data-room="br2"],
.agendawrap .rblock[data-room="br2"] {
  --ink-light: var(--br2-ink-light);
  --ink: var(--br2-ink);
  --solid: var(--br2-solid);
  --frame-fill: var(--br2-wash);
  --frame-ink: linear-gradient(180deg, var(--br2-ink-light), var(--br2-ink));
}
.roomwrap[data-room="br3"],
.agendawrap .rblock[data-room="br3"] {
  --ink-light: var(--br3-ink-light);
  --ink: var(--br3-ink);
  --solid: var(--br3-solid);
  --frame-fill: var(--br3-wash);
  --frame-ink: linear-gradient(180deg, var(--br3-ink-light), var(--br3-ink));
}
.roomwrap[data-room="br4"],
.agendawrap .rblock[data-room="br4"] {
  --ink-light: var(--br4-ink-light);
  --ink: var(--br4-ink);
  --solid: var(--br4-solid);
  --frame-fill: var(--br4-wash);
  --frame-ink: linear-gradient(180deg, var(--br4-ink-light), var(--br4-ink));
}

/* ------------------------------------------------------------ clearance --- */

/* Space under the header before the page starts. The header is held still
   now, so whatever comes first sits against it permanently rather than only
   until you scroll — and on the topic page the chips were touching it
   outright, at a measured 0px. The room page had 8px from .phead, which is
   not enough either. Both take the same clearance from here so the two pages
   begin at the same place. */
.roomwrap[data-room] { padding-top: var(--s-5); }
.roomwrap[data-room] .phead { padding-top: 0; }

/* --------------------------------------------------------------- panels --- */

.roomwrap[data-room] .panel {
  margin-bottom: var(--s-5);
  /* Percentages on all four sides, so the inset tracks the artwork's own
     geometry as the column grows from 320 to 430px. */
  padding: 11% 11% 8%;
  /* The three dashes just inside the top edge. They come out of the mask and
     are drawn here because a panel stretches to its content — left in the
     artwork they slid down with it and landed on the first session. Pinned to
     the top edge instead, which is where the design has them at any height. */
  background-image:
    linear-gradient(var(--ink-light) 0 0),
    repeating-linear-gradient(90deg, var(--ink) 0 26.6%, transparent 26.6% 36.4%);
  background-repeat: no-repeat;
  background-size: 6.65% 3px, 25% 3px;
  background-position: center 20px, center 20px;
}
.roomwrap[data-room] .panel > * { position: relative; z-index: 1; }

/* The speakers panel carries a heading, and sits it at the 15% the artwork
   put its own baked wording, clear of those dashes. */
.roomwrap[data-room] .panel-speakers { padding-top: 15%; }

.roomwrap[data-room] .panel-summary {
  padding: var(--s-4) 9%;
  background-image: none;   /* a thin bar, with no dashes of its own */
}

/* The panel heading — "Speakers". It arrived painted into the artwork in
   English; it is erased during separation and set here so it can be read in
   three languages. */
.roomwrap[data-room] .panel > .shead:first-child {
  margin: 0 0 var(--s-4);
  padding: 0;
  font-family: var(--font-display);
  font-size: var(--size-body);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--solid);
}

/* ------------------------------------------------------------- page head -- */

.roomwrap[data-room] .phead h1 {
  color: var(--solid);
  background: linear-gradient(180deg, var(--ink-light), var(--ink));
  -webkit-background-clip: text;
          background-clip: text;
}
.roomwrap[data-room] .rdot { display: none; }

/* -------------------------------------------------------------- day tabs -- */

.roomwrap[data-room] .tabs { gap: var(--s-3); margin-bottom: var(--s-4); }
.roomwrap[data-room] .tab {
  padding: 0 var(--s-4);
  color: var(--solid);
  font-family: var(--font-display);
  font-size: var(--size-body);
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
/* The selected day floods the interior with the border's own gradient — the
   same shape, filled, which is how the drop draws it. */
.roomwrap[data-room] .tab.on {
  --frame-fill: var(--frame-ink);
  color: var(--fr-row-live);
}

/* --------------------------------------------------------- session rows --- */

.roomwrap[data-room] .srow {
  display: block;
  min-height: 0;
  /* Left clears the artwork's time badge, right its chevron. */
  padding: var(--s-3) 14% var(--s-3) 26%;
  margin-bottom: var(--s-3);
}
/* The badge is lifted artwork, kept at 1:1 so a two-line title cannot turn it
   into an egg.

   Sized against the row's HEIGHT, not its width. The drop draws it at 12.7% of
   the row's width and 65.7% of its height — but only because the artwork's row
   is 5.19 wide to 1 tall, and ours is 3.34: a wrapped title makes a real row
   about 1.55x taller than the drawing. Held at the width fraction it came out
   at 44% of the row's height instead of 66%, and read as a dot lost in a tall
   row. 19% of the width restores the proportion the design actually shows. */
.roomwrap[data-room] .srow-time {
  position: absolute;
  left: 4%;
  width: 19%;
  aspect-ratio: 1;
  top: 50%;
  transform: translateY(-50%);
  display: grid;
  place-items: center;
  padding: 0;
  text-align: center;
  /* The drop sets the time at 82% of the badge; at this size that is 11px. */
  font-size: var(--size-caption);
  font-weight: 700;
  letter-spacing: -.03em;
  color: var(--solid);
}
.roomwrap[data-room] .srow-main { display: block; }
.roomwrap[data-room] .srow-main b { color: var(--solid); font-weight: 700; }
.roomwrap[data-room] .srow-main i { color: var(--text); }
/* The artwork draws its own chevron. */
.roomwrap[data-room] .srow-go { display: none; }

/* States. Nothing was delivered for these, so they are built from the room
   colour: running now brightens the border and lifts the interior, finished
   dims the whole row. */
.roomwrap[data-room] .srow.is-current { --frame-ink: var(--ink-light); }
.roomwrap[data-room] .srow.is-current::before { background: var(--fr-row-live); }
.roomwrap[data-room] .srow.is-finished { opacity: .45; }
.roomwrap[data-room] .srow:active::before { background: var(--fr-row-press); }
.roomwrap[data-room] .flag-now { color: var(--ink-light); }
.roomwrap[data-room] .flag-next { color: var(--solid); }
.roomwrap[data-room] .pulse {
  background: var(--ink-light);
  box-shadow: 0 0 0 3px var(--fr-glow);
}

/* -------------------------------------------------------------- speakers -- */

.roomwrap[data-room] .sp {
  display: block;
  padding: var(--s-3) 8% var(--s-3) 27%;
  margin-bottom: var(--s-3);
}
/* The ring stays in the row's own file — it is the frame's left cap, not an
   element inside it. This box only holds the portrait, over where it falls. */
.roomwrap[data-room] .sp-ring {
  position: absolute;
  left: 0.5%;
  width: 21.5%;
  aspect-ratio: 1;
  top: 50%;
  transform: translateY(-50%);
  display: grid;
  place-items: center;
}
.roomwrap[data-room] .sp-photo,
.roomwrap[data-room] .sp-mono {
  width: 76%;
  height: 76%;
  border: 0;
  border-radius: var(--r-pill);
  background: none;
  color: var(--solid);
  font-size: var(--size-small);
}
.roomwrap[data-room] .sp-photo { object-fit: cover; }
.roomwrap[data-room] .sp-body b { color: var(--solid); font-size: var(--size-body); }
.roomwrap[data-room] .sp-body i { color: var(--text); }

/* BR3's colour runs to red, and red on a person's name is pantang in Chinese
   custom. Its speaker names take a warm off-white instead. Only the names —
   the room's own colour is correct everywhere else on the page. */
.roomwrap[data-room="br3"] .sp-body b { color: var(--br3-name); }

/* ------------------------------------------------------------ resources --- */

.roomwrap[data-room] .res {
  display: block;
  /* The right inset clears the zone the artwork reserves for the file meta,
     which sits from about 79% across. */
  padding: 7% 26% 7% 9.3%;
  margin-bottom: var(--s-3);
}
.roomwrap[data-room] .res.on { color: var(--solid); }
.roomwrap[data-room] .res.off { color: var(--text-low); }
.roomwrap[data-room] .res-label {
  display: block;
  font-family: var(--font-display);
  font-size: var(--size-body);
  letter-spacing: .04em;
  text-transform: uppercase;
}
.roomwrap[data-room] .res-meta,
.roomwrap[data-room] .res-note {
  position: absolute;
  right: 6%;
  top: 50%;
  transform: translateY(-50%);
  max-width: 34%;
  text-align: right;
  color: var(--text);
}

/* ---------------------------------------------------------------- chips --- */

.roomwrap[data-room] .chips { gap: var(--s-2); margin-bottom: var(--s-3); }
.roomwrap[data-room] .chip {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  padding: 0 6%;
  color: var(--solid);
}

.roomwrap[data-room] .topic-title {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--solid);
}

/* --------------------------------------------------------- narrow phones -- */

/* The artwork's 11% panel inset is proportionally faithful and, at 320px,
   costs the session title more than half its column: a Malay title came out
   on five lines. The composition gives way to the words here. Titles wrap in
   full rather than truncate (FR-5.3), so the only lever is width.

   Horizontal only, and as longhands: the top inset is what holds a panel
   heading clear of the dashes along the frame's top edge. */
@media (max-width: 360px) {
  .roomwrap[data-room] .panel {
    padding-left: 6%;
    padding-right: 6%;
  }
}

/* ============================================================================
   CHROME PAGES — DESIGN ROUND 5, delivered 8 September 2026.

   The event map and other information pages. They wear the same three-layer
   mechanism as the rooms and none of their colour: this artwork is drawn in
   the round 2 accent, so it opens a --chromewrap scope rather than extending
   --roomwrap with a fifth palette.

   EVERY FRAME IS THE DELIVERED ARTWORK, as in rounds 3 and 4. Nothing here
   redraws a shape.

   This was the first drop to ship every frame empty as well as filled, so
   unlike the room frames none of these has had wording erased out of it. The
   headings and place names are live text in three languages, which they had to
   be anyway.

   Two shapes serve four roles. The drop delivered the info panel twice, at
   933x276 for parking and 933x248 for hotel, their top edges matching to
   99.9% — one drawing at two heights, exactly as round 4 found with the
   session rows. The taller is taken and stretched. The four Map BR rows are
   likewise one row frame with four sets of words in it.
   ========================================================================= */

.chromewrap {
  --frame-fill: var(--frame-wash);
  --frame-ink: linear-gradient(180deg, var(--accent-light), var(--accent));
  /* More clearance under the held header than a room page takes. 20px was
     what the rooms use, and these two pages read tighter at it: a room page
     opens on a title and a pair of day tabs, this one opens on a title, a
     blurb and three chamfered buttons whose top edge points straight at the
     control bar above it. Scroll the title away and the tabs arrive at the
     same 20px, which is where it was first noticed. */
  padding-top: var(--s-7);
}
.chromewrap .phead { padding-top: 0; }

/* --------------------------------------------------------------- shapes --- */

.chromewrap .tab {
  --m-fill: url('../assets/brand/chrome/tab-map.fill.webp');
  --m-stroke: url('../assets/brand/chrome/tab-map.stroke.webp');
  /* The tab is the one frame in the drop whose gradient runs the other way:
     its dark end is at the top, against light-at-top on everything else. */
  --frame-ink: linear-gradient(180deg, var(--accent), var(--accent-light));
}
.chromewrap .floorplan {
  --m-fill: url('../assets/brand/chrome/frame-map.fill.webp');
  --m-stroke: url('../assets/brand/chrome/frame-map.stroke.webp');
}
.chromewrap .places li {
  --m-fill: url('../assets/brand/chrome/row-place.fill.webp');
  --m-stroke: url('../assets/brand/chrome/row-place.stroke.webp');
  --m-hi: url('../assets/brand/chrome/row-place.hi.webp');
  /* A place row is painted like a session row, not like a panel: charcoal at
     55% with near-white edge segments, and a flat border rather than a
     gradient. Measured off the artwork, which uses the room pages' neutrals
     here without using their colour. */
  --frame-fill: var(--fr-row-wash);
  --frame-ink: var(--accent);
}
.chromewrap .prose {
  --m-fill: url('../assets/brand/chrome/panel-info.fill.webp');
  --m-stroke: url('../assets/brand/chrome/panel-info.stroke.webp');
}

/* ----------------------------------------------------------------- tabs --- */

.chromewrap .tabs {
  gap: var(--s-2);
  margin-bottom: var(--s-3);
  align-items: stretch;
}
.chromewrap .tab {
  /* The drop draws one tab 274px wide and the page has to fit three of them
     across 320-430px, so the label is the thing that gives. BREAKOUT ROOMS is
     the widest of the three in every locale and already wraps in the mockup;
     at --size-small its first line overran the chamfer by 14px. */
  padding: var(--s-2) var(--s-1);
  color: var(--text);
  font-family: var(--font-display);
  font-size: var(--size-caption);
  font-weight: 700;
  line-height: var(--leading-tight);
  letter-spacing: .03em;
  text-transform: uppercase;
}
/* NOT DRAWN. All three buttons in the drop are the same frame with different
   words — same wash alpha, same ink count — so nothing says which tab you are
   on, and the whole page is a tab picker. This is the room pages' answer to
   the same gap: the selected tab floods its interior with the border's own
   gradient and the label goes dark against it. Raised with the designer. */
.chromewrap .tab.on {
  --frame-fill: var(--frame-ink);
  /* The rooms use --fr-row-live for this, but their gradients are pale golds
     and pinks. The accent is darker: #47474D on the tab's #4BBAEA end measures
     4.2:1, under the 4.5 floor. The page ground clears it at 8.1:1. */
  color: var(--space);
}

/* ------------------------------------------------------------ floorplan --- */

/* Held at the artwork's own aspect rather than the floorplan's. The frame is
   881x765 and the mockup uses it at that ratio; the floorplans themselves do
   not exist yet, so there is no second aspect to reconcile with — and when
   they arrive they are fitted inside rather than stretching the frame, which
   is the only way an ornamented corner survives.

   Padding clears the ornament: it measures 2.8% and 3.0% on the sides and
   5.6% and 3.7% top and bottom, and the corner brackets reach further in. */
.chromewrap .floorplan {
  aspect-ratio: 881 / 765;
  padding: 7% 5%;
  margin-bottom: var(--s-3);
  border: 0;
  border-radius: 0;
  background: none;
  overflow: visible;
  display: grid;
  place-items: center;
}
.chromewrap .floorplan img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.chromewrap .floorplan-missing { padding: 0; }
/* .floorplan is zoom-in everywhere, and the JS sets .is-missing when the image
   404s. An empty frame is not zoomable, and with no floorplans delivered yet
   that is the state this page is in — a whole screen of it. */
.floorplan.is-missing { cursor: default; }

/* --------------------------------------------------------------- places --- */

.chromewrap .places { display: grid; gap: var(--s-2); }
.chromewrap .places li {
  /* Left clears the artwork's bracket cap, right its chamfered corner. */
  padding: var(--s-4) 8% var(--s-4) 7%;
  border-bottom: 0;
}
/* Not the display face and not uppercase: the drop sets the place name in the
   body face, bold and title case, and only the tabs and panel headings in the
   display face. */
.chromewrap .places b {
  color: var(--accent);
  font-size: var(--size-row);
  font-weight: 700;
  /* The name and the direction under it are one thing, so they should sit
     closer to each other than either sits to the frame. They were the wrong
     way round: 9px between the two lines against 8px from the name to the
     border above it. Body leading plus a 4px margin is spacing for separate
     paragraphs, and this is a label and its answer. */
  line-height: var(--leading-tight);
}
/* Tight here too. Half the gap between the two lines was this line's own top
   leading rather than anything between them, so tightening only the name above
   it could not close it: body leading on an 11px caption is 3px of air above
   the glyphs before the gap proper even starts.

   Safe for the content there is — the longest direction in the set is Malay
   and it still sits on one line at 320px. A wrapping one would be tighter than
   the room pages set a caption, which is the thing to look at if these ever
   grow. */
.chromewrap .places span {
  color: var(--text);
  line-height: var(--leading-tight);
}

/* ---------------------------------------------------------------- prose --- */

/* The heading sits above its panel rather than inside it, which is how the
   drop draws it — and is why these panels needed no wording erased. */
.chromewrap .prose { padding: var(--s-3) 6%; margin-bottom: var(--s-3); }
.chromewrap .prose:last-child { margin-bottom: 0; }
.chromewrap .prose-head {
  margin: 0 0 var(--s-2);
  font-family: var(--font-display);
  font-size: var(--size-row);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--text);
}
.chromewrap .prose p { color: var(--text); }

/* ---------------------------------------------------------------- width --- */

/* The mockup insets this column further than --edge does — its map frame runs
   about 82% of the screen, against 92% at --edge — and the frame is 1.15 tall
   for its width, so those ten points cost a third of the page in height before
   the room list starts.

   Taken on the column rather than on the frame alone. Insetting only the frame
   left it narrower than the rows beneath it, which is the opposite of the
   drop: there the two align, with the frame a hair wider.

   Not below 380px, where the width is needed for the words. BREAKOUT ROOMS is
   the constraint — three tabs across 320px already have only 6px to spare in
   Malay, and this would take 8 of them. */
@media (min-width: 380px) {
  .chromewrap { padding-inline: var(--s-3); }
}

/* --------------------------------------------------------- narrow phones -- */

/* Same trade as the room panels: the artwork's inset is proportionally
   faithful and costs the words too much at 320px. */
@media (max-width: 360px) {
  .chromewrap .prose { padding-left: 4%; padding-right: 4%; }
  .chromewrap .places li { padding-left: 5%; padding-right: 5%; }
  .chromewrap .tab { letter-spacing: 0; }
}

/* ============================================================================
   OVERALL AGENDA — DESIGN ROUND 8, delivered 10 September 2026.

   Four room blocks, one open at a time: a header bar that toggles, and under
   the open one a panel of that room's sessions on the chosen day. The page
   head and the day tabs are chrome, in the accent; each block is its room, in
   that room's own colours.

   EVERY FRAME IS THE DELIVERED ARTWORK, as in rounds 3 to 6. Nothing here
   redraws a shape. tools/separate-frames.py splits the drop into masks in
   assets/brand/agenda/ and the three-layer mechanism above paints them.

   All four rooms' frames are one drawing, so one set of shapes wears the four
   room palettes the room pages already define: no new colour, no new token.
   Two shapes are not even new. The day tab is the room pages' day tab drawn
   in the accent, and the time circle in a row is their badge-time.

   The open block is cut out of the drop's joined drawing of header and panel,
   not assembled from its separate pieces — AGENDA_FRAMES in the tool says why,
   and design/round-8/README.md has everything measured.
   ========================================================================= */

.agendawrap {
  /* The accent, for the page head and the day tabs. Each .rblock swaps in its
     room's palette from the rules the room pages use. */
  --frame-fill: var(--frame-wash);
  --frame-ink: linear-gradient(180deg, var(--accent-light), var(--accent));
  /* The chrome pages' clearance under the held header: this page opens the
     way theirs do, on a title, a line of blurb and a row of tabs. */
  padding-top: var(--s-7);
}
.agendawrap .phead { padding-top: 0; }

/* ----------------------------------------------------------------- tabs --- */

/* The room pages' day tab — the drop's day frame agrees with it to within
   2px — in the accent, with the dark end at the top as the drop draws it. */
.agendawrap .tabs { gap: var(--s-3); margin-bottom: var(--s-5); }
.agendawrap .tab {
  --m-fill: url('../assets/brand/rooms/tab-day.fill.webp');
  --m-stroke: url('../assets/brand/rooms/tab-day.stroke.webp');
  --frame-ink: linear-gradient(180deg, var(--accent), var(--accent-light));
  padding: 0 var(--s-4);
  color: var(--text);
  font-family: var(--font-display);
  font-size: var(--size-body);
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
/* NOT DRAWN. Both tabs in the mockup are the same frame. This is the answer
   the room pages and round 5 already give: the selected tab floods with the
   border's own gradient and its label goes dark — --space, which round 5
   measured at 8.1:1 on the accent, where the room pages' grey fails. */
.agendawrap .tab.on {
  --frame-fill: var(--frame-ink);
  color: var(--space);
}

/* --------------------------------------------------------------- header --- */

.agendawrap .rblock { margin-bottom: var(--s-3); }

/* Drawn 849x92, which is 31-40px tall across 320-430px. It is a control, so
   it is held at the 44px touch minimum (NFR-7) and the frame stretches to
   it — 1.4x at 320, 1.2x at 375, 1.1x at 430 — the trade the room pages' day
   tabs already make. The chevron is the artwork's and stretches with it, as
   a session row's does. */
.agendawrap .rblock-head {
  --m-fill: url('../assets/brand/agenda/head.fill.webp');
  --m-stroke: url('../assets/brand/agenda/head.stroke.webp');
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  width: 100%;
  min-height: var(--tap-min);
  /* Left where the drop starts its words, 56 of 849; right clear of the
     chevron, which starts 93.4% across. */
  padding: var(--s-1) 10% var(--s-1) 6.6%;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
/* Pressed, instantly — header and rows alike. Nothing drawn; the hub cards'
   answer. It lifts words and ground together, so no label loses contrast for
   the moment a finger is on it, which a lighter ground on its own would. */
.agendawrap .rblock-head:active,
.agendawrap .srow:active { filter: brightness(1.3); }

/* Name over theme, not beside it.

   The drop sets the two side by side at artboard sizes: scaled to a 375px
   phone, the name comes out at 11.4px and the theme at 7.6px, smaller than
   anything else in the handbook. At the sizes the rest of the site reads at,
   side by side no longer fits — the pair overflows the bar for 7 of the 12
   room-and-language pairs at 320px, 5 at 375 and 2 at 430. Stacked, both fit
   in every language at every width, and two lines are what the 44px bar
   holds anyway.

   Tight leading in all three languages, Chinese included, or the pair
   outgrows the bar. */
.agendawrap .rblock-head b {
  font-family: var(--font-display);
  font-size: var(--size-row);
  font-weight: 700;
  letter-spacing: .04em;
  line-height: var(--leading-tight);
  text-transform: uppercase;
  color: var(--solid);
}
.agendawrap .rblock-theme {
  font-size: var(--size-caption);
  line-height: var(--leading-tight);
  color: var(--fr-edge);
}

/* Open. The top of the joined drawing: this bar with the V chevron, and the
   panel's right border rising into its cut corner. Lit flat, as that drawing
   lights it: it holds the bar at the gradient's light end and runs the
   gradient down the panel beneath, so an open room reads as one object. */
.agendawrap .rblock-head[aria-expanded="true"] {
  --m-fill: url('../assets/brand/agenda/head-open.fill.webp');
  --m-stroke: url('../assets/brand/agenda/head-open.stroke.webp');
  --frame-ink: var(--ink-light);
}

/* ---------------------------------------------------------------- panel --- */

/* The joined drawing below the cut. All it has at the seam is its two side
   strokes, so it meets the bar at any height. It stretches to its sessions —
   six a day where the mockup draws four — and the notches in its sides
   stretch with it, as the room page's day panel does.

   Insets are the drop's, as percentages of the width so they track the
   artwork from 320 to 430px. Rows start 34 of 848 below the seam and 78 in
   from either side, which is where the mockup puts them. */
.agendawrap .rblock-body {
  --m-fill: url('../assets/brand/agenda/panel-room.fill.webp');
  --m-stroke: url('../assets/brand/agenda/panel-room.stroke.webp');
  padding: 4% 9% 6% 9.2%;
  margin-bottom: var(--s-3);
}
.agendawrap .rblock-body[hidden] { display: none; }

/* ----------------------------------------------------------------- rows --- */

/* Its own row, not the room page's: drawn in the room's wash rather than
   charcoal, with its border broken into two brackets. */
.agendawrap .srow {
  --m-fill: url('../assets/brand/agenda/row-agenda.fill.webp');
  --m-stroke: url('../assets/brand/agenda/row-agenda.stroke.webp');
  display: block;
  /* Left clears the badge, right the artwork's chevron, 92.4% across. */
  padding: var(--s-3) 12% var(--s-3) 26%;
  margin-bottom: 3.6%;
}
.agendawrap .srow:last-child { margin-bottom: 0; }

/* The room pages' badge-time, stroke only. Its fill mask is the whole square,
   and inside this row the circle is the same wash as the row around it, so a
   painted fill would lay a darker square behind the time.

   Placed and sized as the room page places it, for the reason round 4 found
   there: the drop draws the circle for a one-line row, and at readable sizes
   titles here wrap to two. */
.agendawrap .srow .srow-time {
  --frame-fill: transparent;
  --m-stroke: url('../assets/brand/rooms/badge-time.stroke.webp');
  position: absolute;
  left: 4%;
  width: 19%;
  aspect-ratio: 1;
  top: 50%;
  transform: translateY(-50%);
  display: grid;
  place-items: center;
  padding: 0;
  font-size: var(--size-caption);
  font-weight: 700;
  letter-spacing: -.03em;
  color: var(--solid);
}
.agendawrap .srow-main { display: block; }
.agendawrap .srow-main b { color: var(--solid); font-weight: 700; }
.agendawrap .srow-main i { color: var(--text); }
.agendawrap .srow-go { display: none; }   /* the artwork draws its own */

/* A halo in the page ground behind every word set in a room colour.

   The drop washes both the panel and the row with the room's solid at 15%, and
   nothing darker, so the starfield reads straight through — which is the look,
   until the milky way crosses an open panel. Measured right beside the letters
   on day one at 375px, the brightest twentieth of that ground took the worst
   BR2 title to 3.2:1 and BR1's to 3.6:1 — four rows of 24 under NFR-8's 4.5.
   The room pages never met this: their rows are charcoal at 55%, which darkens
   the sky first.

   The home captions' answer to words over artwork: a halo, not a scrim. It
   keeps a dark edge round each glyph, is barely there wherever the ground is
   already dark, and leaves every frame exactly as drawn. One tight layer
   under the captions' soft one still left BR2 at 4.1:1; two bring the worst
   of the 24 to 4.9:1. */
.agendawrap .rblock :is(.rblock-head b, .srow-main b, .srow-time, .rblock-more) {
  text-shadow: 0 0 3px var(--space), 0 0 3px var(--space), 0 1px 8px var(--space);
}

/* UP NEXT is drawn in this drop, in near-white. The room pages were never
   given it and set it in the room colour; this page follows its own artwork. */
.agendawrap .flag-next { color: var(--fr-edge); }
.agendawrap .flag-now { color: var(--ink-light); }
.agendawrap .pulse {
  background: var(--ink-light);
  box-shadow: 0 0 0 3px var(--fr-glow);
}

/* States. None drawn. Finished dims but stays tappable, as on the room pages.

   Running now lights the border — and the time circle with it, which inherits
   the ink — but does NOT take the room pages' lifted interior. That lift is
   #47474D, and BR2's title on it measures 4.1:1, under the 4.5 floor; on this
   row's own wash it is over 5. The flag and its pulse already say "now", so
   the lift was only ever emphasis. */
.agendawrap .srow.is-current { --frame-ink: var(--ink-light); }
.agendawrap .srow.is-finished { opacity: .45; }

/* ------------------------------------------------------------ room link --- */

/* The way into the room's own page, which carries its location. NOT DRAWN: the drop's panel ends after its rows. FR-4.2 needs
   the agenda to link into each room, so it stays where it was before the
   artwork, at the foot of the open panel: live text in the room's colour, and
   no frame. */
.agendawrap .rblock-more {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: var(--tap-min);
  margin-top: var(--s-2);
  color: var(--solid);
  font-family: var(--font-display);
  font-size: var(--size-caption);
  letter-spacing: .08em;
  text-transform: uppercase;
  text-decoration: none;
}
.agendawrap .rblock-more i { font-style: normal; }
.agendawrap .rblock-more:active { filter: brightness(1.3); }

/* ---------------------------------------------------------------- width --- */

/* The chrome pages' column: the mockup insets this page further than --edge
   does, and above 380px the width is not needed for the words. */
@media (min-width: 380px) {
  .agendawrap { padding-inline: var(--s-3); }
}

/* The room panels' trade, for the same reason: the drop's 9% inset is
   faithful, and at 320px it leaves a session title 144px. At 6% — still clear
   of the panel's notches, which reach 3.8% and 95.9% — Malay takes 50 title
   lines on day one instead of 57, 60 instead of 65 on day two, and no title
   runs to four. */
@media (max-width: 360px) {
  .agendawrap .rblock-body { padding-left: 6%; padding-right: 6%; }
}
