/* ============================================================
   FAMILY HUB — section 05.

   One device, four things it does, then it goes to the wall.

   The pad is the SAME element throughout — the four scenes cross-fade
   inside its screen while the frame never moves, so it reads as a
   screen changing rather than as four pictures of a screen. At the end
   it docks: it does not shrink on the scroll the way the intro's film
   does, it steps down in one eased move, because a device being hung
   up is an event, not a scrub.

   🔴 The stage is a FLEX COLUMN, not three absolutely-positioned
   layers. The heading, the pad and the caption all size off their own
   content — the heading wraps to two lines on a phone, the
   caption at 2.3vw — so stacking them by hand means picking a top
   offset that is wrong at some width. In a column they simply cannot
   collide, and the pad's clamp below is what gives way.

   Everything inside the screen is sized in `em` off one root on
   .hub__screen, so the whole UI scales with the viewport as a unit and
   nothing has to be re-tuned per breakpoint.
   ============================================================ */

.hub {
  position: relative;
  /* 🔴 720svh, up from 620 — and the extra 100 is ALL tail. The section
     used to end 31svh after the button appeared, so the finished picture —
     device on the wall, line above it, pill under it — was on screen for
     about a third of a flick and then gone. That is the one view the whole
     scene has been building, and it was the shortest.

     The phase marks in hub.js are re-anchored against the new range so
     every beat BEFORE the dock still costs the same scroll it always did
     (measured in svh, not in p: 115 / 105 / 93 / 84 for the four scenes,
     74 to the thesis, 19 to the button). Only the hold at the end grows,
     31svh → 130svh. The resistance is bought with distance, exactly the
     way the hero deck buys it — see --slide-span in hero.css. */
  height: 720svh;              /* hub.js maps this to p 0 → 1 */
  background: var(--page-bg);   /* was --nes-cream — see --page-bg in styles.css */
  color: var(--nes-ink);
}

.hub__stage {
  position: sticky; top: 0;
  height: 100svh;
  min-height: 640px;
  overflow: hidden;
  isolation: isolate;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  /* 🔴 Near-symmetric, and it has to stay that way: this padding is what
     the DEMO composition centres in — title, device, caption — and that one
     is composed against the window, because for most of the section there
     is no room behind it, only the page's own ground.

     It is NOT the lever for the docked view. I briefly made the foot 15svh
     to lift the device off the new room photo's shelving, which worked and
     tipped the demo visibly top-heavy — the two states share this box but
     want different centres, so one padding cannot serve both. The docked
     view has three levers of its own (--dock-y on the pad, `top` on the
     thesis, and the button riding --pad-bot); they are where that lift
     lives now.

     🔴 3 / 8 rather than 6 / 5, and it IS the demo's lever — which is what
     the paragraph above says it is for. What it fixes: a flex column
     centres the WHOLE GROUP, and the group is a light line, a gap and a
     537px frame, so centring it leaves the FRAME low. Measured at
     1440×900: frame centre 524.5 against a window centre of 450, i.e. 75px
     down the screen, which is exactly the "屏幕偏下" — the group was
     centred and the mass inside it was not.

     Moving 3svh from the head to the foot lifts the whole column by 3svh
     and takes 27px of that back. The rest cannot be had here: the frame is
     60% of the window's height and the title needs a band of its own above
     it, so pulling the frame the whole way would put the headline under
     the header bar. Title at ~107 with the bar's condensed edge at 66 is
     where it stops.

     🔴 5svh of the old spread is not free — the docked device rides this
     column too, so the lift lands on the wall as well. Half of the head's
     margin change plus all of this is what --dock-y gives back; see the
     arithmetic there. */
  padding: 3svh max(5vw, 32px) 8svh;
}

/* The room the pad is hung in. It only comes up once the pad is on the
   wall — before that the pad fills the screen and there is no room to
   see. There are THREE of them and they cycle: this is level 0, the one
   with no layer over it, and ::before / ::after carry the other two (see
   the room block at the foot of this file).

   🔴 hub-room-1.webp, NOT hub-bg.png — they are different pictures and the
   file this points at was the wrong one twice. hub-bg.png is byte-for-byte
   assets/hub-room-2.webp, which ::before already carries, so pointing here
   at it puts the SAME living room on two of the three levels: the cycle
   runs blue rim → green rim → wood rim over living → living → bedroom, and
   the first change of every loop appears to do nothing at all.

   assets/hub-room-1.webp is 1672×941, i.e. 16:9. That is WIDER than a typical
   desktop stage (1440×900 is 1.6), so `cover` scales it to the box HEIGHT
   and crops the sides — which means the `26%` in background-position is
   inert on any window wider than 16:9 and only starts doing anything on a
   very wide one. It is left in for that case. (It was tuned for the old
   3800×2318 photo, and the note here used to claim the photo was portrait;
   neither of those has been true for a while.)

   The radial gradient stays underneath as the ground colour: it is what
   shows if the photo is missing or still loading, and it keeps the edges
   warm rather than white. */
.hub__glow {
  position: absolute; inset: 0; z-index: 0;
  background-image: url("assets/hub-room-1.webp"),
    radial-gradient(72% 58% at 50% 44%,
      var(--nes-sand) 0%,
      color-mix(in srgb, var(--nes-tan) 55%, var(--nes-cream)) 100%);
  background-size: cover, cover;
  background-position: center 26%, center;   /* see above — vertical only bites past 16:9 */
  background-repeat: no-repeat, no-repeat;
  opacity: 0;
  transition: opacity 1.1s var(--ease-out);
}
.hub.is-docked .hub__glow { opacity: 1; }

/* ---------- the copy above the pad ----------
   ONE SLOT, TWO LINES. The title holds it while the demo plays; the pad
   docks, the title leaves and the thesis takes the same spot.

   🔴 The fade lives on the two LINES, not on this box. It used to be on
   .hub__head, which was fine while the head held only the title — but the
   thesis now shares the slot, and an opacity on the shared parent would
   take the incoming line out with the outgoing one.

   The thesis is absolute so the slot's height is the title's at all times.
   In flow it would add its own height above the pad and push the pad down
   the moment it mounted, which is a layout shift for a line that is not
   even visible yet. */
.hub__head {
  position: relative; z-index: 3;
  flex: none;
  /* 🔴 THIS IS THE WHOLE GAP TO THE FRAME, and it is bigger than it looks
     it should be. The slot is as tall as the TITLE; the thesis is absolute
     and hangs out of the bottom of it, so in the docked view the space that
     reads is this margin minus that overhang — which is why it used to be
     2svh and read as almost nothing above the app.

     🔴 It costs the pad nothing to grow. The stage is a centred flex
     column, so adding to this pushes the column's top up by half of it and
     the pad's top down by the other half; the pad keeps its own size at
     every width. The one thing it does move is the DOCKED device, whose
     centre rides the pad's layout centre — see --dock-y below, which is
     re-anchored by half of whatever changes here.

     🔴 6svh, and it has been walked in from both ends. At 5svh the ink
     sat 35px off the frame under a 64px line — the spacing of a label on
     a thing rather than of a headline above one, and it read as stuck to
     the app. At 9svh it was 71px and read as detached. 6svh is 44px,
     ~0.7× the type size, which is the one that reads as a headline WITH
     the thing under it. Both ends of that range were looked at on screen;
     this is the middle, not a first guess.

     🔴 The ink, not the box: with the leading at 1.14 the box's floor and
     the letters' floor are ~10px apart, so the margin alone overstates
     the gap you can see. Measure from the range's client rects. */
  margin-bottom: 6svh;
  text-align: center;
  pointer-events: none;
}
.hub__title {
  opacity: 0; transform: translateY(14px);
  transition: opacity .8s ease, transform .8s var(--ease-out-expo);
}
.hub.is-lit  .hub__title { opacity: 1; transform: none; }
.hub.is-docked .hub__title { opacity: 0; transform: translateY(-10px); }
/* and it stays gone while the thesis is still clearing out of the shared
   slot on the way back up — see the note on thesisTl in hub.js */
.hub.is-slot-busy .hub__title { opacity: 0; transform: translateY(-10px); }

/* one line, allowed to wrap on a phone — the stage is a column, so a
   second line costs the pad a little height and nothing else. (The two
   clauses used to be told apart by a roman/italic turn; see the note on
   `em` in styles.css for why nothing leans any more.) */
/* 🔴 THE SAME LOCKUP AS .pv__title, to the property. Asked for, and the
   two headings are the same kind of thing: a drawn mark standing next to
   a sentence — the padlock there, the wordmark here — where a 400-weight
   line reads as a caption to the mark rather than as one line with it.
   Size, leading, weight and tracking are all privacy.css's; if that one
   moves, move this with it. (--heading--display is the shared clamp, so
   both headings agree at every width and not just at 1440.)

   🔴 IT IS NOT .hub__thesis'S TYPE, and it was for one turn — matching the
   line it shares the slot with was asked for, then reverted to this. Worth
   knowing what the lockup costs, because the argument for the other side
   is real and will come back: these two are the only lines on the page
   that occupy THE SAME SPOT, the title while the demo plays and the thesis
   once the pad docks, never both. At 1440 that swap now goes 64px/600 →
   64.8px/500 — close enough in size to read as a wobble rather than a
   contrast, while the weight steps a whole notch. If it ever reads wrong
   in motion, that is what it is; the fix is one of these two rules taking
   the other's four properties, and the choice is which relationship the
   line belongs to more — the mark beside it, or the line that replaces
   it. This file's answer is the mark. */
.hub__title {
  margin: 0;
  font-family: var(--font--primary);
  font-size: var(--heading--display);
  line-height: .92;
  font-weight: 600;
  letter-spacing: -.02em;
  text-wrap: balance;
}

/* ---------- the brand word is the LOGO, not the word ----------
   🔴 Sized and seated in em, so it rides the clamp() above it: the height
   is the cap height of this line and the -.1em drops it by the share of
   the file that is the y's descender (viewBox 42→157, letters bottom out
   at ~136), which is what puts the logo's baseline on the sentence's own
   instead of leaving it floating a tenth of a line high.

   The file's letters are drawn in the site's ink and its bead in the brand
   blue, so an <img> is enough — nothing here needs to recolour it. */
.hub__title-logo {
  display: inline-block;
  height: .74em;
  width: auto;
  vertical-align: -.1em;
}

/* ---------- the device ----------
   🔴 The bezel is a box-shadow SPREAD, not padding or a border. Padding
   would shrink the screen when it appears and re-lay-out every row of
   the UI inside it mid-transition; a spread ring is painted outside the
   box and costs the layout nothing. It also gets the outer corner right
   for free — a shadow's outer radius is the border radius plus the
   spread, which is exactly how a real bezel meets a screen. */
.hub__wrap {
  position: relative;
  z-index: 2;
  flex: none;
  width: min(980px, 84vw, 106svh);
  aspect-ratio: 16 / 9;
}
.hub__pad {
  position: relative; z-index: 2;
  width: 100%;
  height: 100%;
  transform: translateY(var(--dock-y, 0px)) scale(var(--dock-s, 1));
  /* 🔴 TINY, NOT REGULAR, and the number is the 3D unit's. --regular is
     2rem: a card's corner, and on a 950px-wide screen it reads as a soft
     rectangle rather than as hardware. The housing in nestie-unit.js draws
     its screen corner at SCREEN_R = 2.2% of the screen's width, which is
     21px at this pad's widest — --tiny is 20px, i.e. the same corner. That
     matters beyond taste: the canvas cross-fades onto this box at the dock,
     and a corner that changes during the fade is the one thing that gives
     the swap away. */
  border-radius: var(--section-radius--tiny);
  background: var(--nes-white);
  /* --bezel-c is the FLAT FALLBACK's rim, and it is read here rather than
     in a later override so that trimming this file cannot orphan it: the
     rooms set it (see [data-room] at the foot) and this is the only thing
     that reads it. 🔴 It is not the device — with WebGL up the ring is
     never painted at all, because .hub--3d drops it and the housing is the
     object in hub3d.js. */
  box-shadow:
    0 0 0 var(--bezel, 0px) var(--bezel-c, var(--nes-ink)),
    0 28px 70px -24px color-mix(in srgb, var(--nes-ink) 34%, transparent);
  transition:
    transform 1.1s var(--ease-out-quint),
    box-shadow 1.1s var(--ease-out-quint);
}
/* Camera was ours, not jinghe's. Demo has no bezel, so no cam. */
.hub__cam { display: none; }
/* 🔴 THE DOCK IS TUNED TO THE PHOTOGRAPH, and the photograph changed. The
   room's shelving unit is taller than the credenza it replaced and it now
   has objects standing on it — books, a mug, a clock — so the band of bare
   wall this whole composition hangs in lost about 90px at 1440×900. At the
   old .44/-2svh the device's bottom edge came down onto the mug and the
   pill landed on the shelf front, which reads as a screen PROPPED on the
   furniture rather than hung above it.

   Smaller and higher, then: the pad clears the objects and the button sits
   on the wall with them under it.

   🔴 The lift lives HERE and on .hub__thesis's `top`, not on the stage's
   padding. Padding centres the flex column, and that column is also the
   demo composition, which is composed against the window rather than
   against the wall — lifting it there tipped the demo top-heavy to fix the
   dock. --dock-y is a transform: it moves the docked device and nothing
   else, and the button follows because it rides the pad's measured rect.

   These numbers are one measurement of the room photo in three places
   (here, .hub__thesis, .hub__close). Swap the photo and re-check all
   three against the new shelf line. */
.hub.is-docked .hub__pad {
  --dock-s: .37;
  /* 🔴 -15svh, RE-ANCHORED, not re-tuned by eye. The pad's layout centre
     is (stage centre + head height / 2) — the pad's own height cancels out
     of that — so opening .hub__head's margin from 2svh to 5svh and setting
     the title in the display size moved this device ~17px down the wall
     for reasons that have nothing to do with the wall. Half of what the
     head grew, taken back here. The photograph's tuning is unchanged.

     Then the margin went on to 9svh and this took the other half of that:
     4svh in the head, 2svh back here. It has since settled at 6svh, and
     the stage's padding moved 3svh from its head to its foot to lift the
     frame off the bottom of the window — so this number owes back half of
     the margin's 3svh AND the whole of the padding's 3svh, which is why
     it is 4.5svh less negative than it was. Verified rather than assumed:
     the docked pad lands at y=290 at 1440×900, which is where it sat
     before any of this moved.

     🔴 It was briefly `calc(-15svh - .5vw)`, and that .5vw is worth
     understanding even though it is gone: the title had also taken
     .hub__thesis's 1.14 leading, which grew the head by ~15px of LINE BOX
     on top of the margin. That half does not scale with the window, it
     scales with the TYPE — 4.5vw through the middle of its clamp — so the
     correction genuinely needed two units. The lockup was restored, the
     leading went back to .92, and with it the second term.

     Anything that changes the head's height owes this number half of it,
     in whatever unit that height was measured in. */
  --dock-y: -10.5svh;
  --bezel: 15px;
  box-shadow:
    0 0 0 var(--bezel) var(--bezel-c, var(--nes-ink)),
    0 46px 90px -30px color-mix(in srgb, var(--nes-ink) 55%, transparent);
}

/* the screen. One font-size here drives the entire UI below it. */
.hub__screen {
  position: absolute; inset: 0;
  display: flex;
  border-radius: inherit;
  overflow: hidden;
  font-family: var(--font--body);
  font-size: clamp(10px, .97vw, 14px);
  background: #f5f3ee;
}

/* 1920×1080 Flutter pad, scaled to the screen box */
.hub__inner {
  position: absolute;
  top: 0;
  left: 0;
  width: 1920px;
  height: 1080px;
  transform-origin: top left;
  background: #f5f3ee;
}
.hub__inner iframe {
  width: 1920px;
  height: 1080px;
  border: 0;
  display: block;
  background: #f5f3ee;
}
.hub__catch {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
}

/* Calendar fly-in cards live ON the stage, not on the bezel. */
.hub__flies {
  position: absolute;
  inset: 0;
  z-index: 8;
  pointer-events: none;
  perspective: 1400px;
  perspective-origin: 50% 45%;
}
.hub.is-docked .hub__flies { visibility: hidden; }
.hub__fly-card {
  position: absolute;
  left: 0;
  top: 0;
  box-sizing: border-box;
  border-radius: 12px; /* PadRadius.eventCard = NRadius.md */
  padding: 12px 14px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 8px;
  font-family: Nunito, var(--font--body), sans-serif;
  transform-origin: center center;
  will-change: transform;
  opacity: 1;
  box-shadow: 0 10px 28px rgba(28, 25, 23, .16);
}
.hub__fly-card .t {
  /* color: PadEventColor.readableOn(fill, ownerSolid) — set per card */
  font-size: 15px;
  font-weight: 700;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: none;
}
.hub__fly-card .meta {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 8px;
  min-height: 28px;
}
.hub__fly-card .s {
  /* color: lerp(title, fill, 0.28) — set per card */
  font-size: 12px;
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  flex: 1;
  min-width: 0;
}
.hub__fly-card .avs {
  display: flex;
  flex: none;
  align-items: center;
  direction: ltr;
}
.hub__fly-card .av {
  flex: none;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  color: #fff;
  box-shadow: 0 0 0 2px #fff;
  margin-left: -8px;
}
.hub__fly-card .av:first-child { margin-left: 0; }
.hub__fly-card .av-photo {
  overflow: hidden;
  background: #e7e5e0;
  padding: 0;
}
.hub__fly-card .av-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.hub__fly-card .av svg {
  width: 15px;
  height: 15px;
  display: block;
}
.hub__fly-card .av.is-more {
  background: #fff;
  color: #1C1917;
  font-size: 11px;
  font-weight: 800;
  letter-spacing: -.02em;
}
.hub__fly-card.is-list {
  padding: 0;
  height: auto;
  box-shadow: 0 12px 32px rgba(28, 25, 23, .16);
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  overflow: hidden;
}
.hub__fly-card.is-list .hd {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 10px 12px 10px 14px;
  font-weight: 700;
  line-height: 1.2;
}
.hub__fly-card.is-list .hd-t {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.hub__fly-card.is-list .av {
  width: 24px;
  height: 24px;
  margin-left: -7px;
}
.hub__fly-card.is-list .av svg {
  width: 13px;
  height: 13px;
}
.hub__fly-card.is-list .it {
  flex: none;
  padding: 8px 14px 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  overflow: visible;
}
.hub__fly-card.is-list .row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  font-weight: 500;
  line-height: 1.25;
  white-space: nowrap;
  overflow: hidden;
  will-change: transform, opacity;
}
.hub__fly-card.is-list .box {
  flex: none;
  width: 14px;
  height: 14px;
  border-radius: 3px;
  border: 1.5px solid currentColor;
  opacity: .7;
}
.hub__fly-card.is-list .row.is-done { text-decoration: line-through; }

/* Wispr Flow toast words: Figtree SemiBold on a white capsule. */
.hub__word {
  position: absolute;
  left: 0; top: 0;
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  padding: 12px 22px;
  border-radius: 999px;
  background: #fff;
  color: #1A1A1A;
  font-family: Figtree, "Google Sans", var(--font--primary), sans-serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -.02em;
  line-height: 1.15;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: 0 10px 24px rgba(26, 26, 26, .14);
  will-change: transform;
  pointer-events: none;
}
/* Source scraps — sticky / mail / paper / chat. Not Pad event chrome. */
.hub__src {
  position: absolute;
  left: 0; top: 0;
  box-sizing: border-box;
  overflow: hidden;
  font-family: Nunito, var(--font--body), sans-serif;
  will-change: transform;
  pointer-events: none;
}
.hub__src--sticky {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-start;
  text-align: left;
  width: 148px;
  min-height: 158px;
  background: #EEDC4F;
  color: #3F3826;
  padding: 22px 16px 18px;
  border-radius: 1px;
  box-shadow:
    0 1px 1px rgba(70, 50, 8, .10),
    0 8px 16px rgba(50, 36, 8, .16);
}
.hub__src--sticky::before {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 9px;
  background: rgba(70, 50, 8, .05);
}
.hub__src--sticky .t {
  font-family: Nunito, var(--font--body), sans-serif;
  font-size: 20px;
  font-weight: 800;
  line-height: 1.15;
}
.hub__src--sticky .s {
  font-size: 13px;
  font-weight: 600;
  opacity: .7;
  margin-top: 8px;
}
.hub__src--paper {
  background: #fff;
  color: #2F2C28;
  border-radius: 2px;
  padding: 16px 14px 14px 22px;
  box-shadow: 0 14px 24px rgba(28,25,23,.16);
  background-image:
    linear-gradient(90deg, transparent 18px, #E24B4B 18px, #E24B4B 19px, transparent 19px),
    repeating-linear-gradient(
      180deg,
      transparent 0 27px,
      #9EC5E8 27px 28px
    );
}
.hub__src--paper .t {
  font-family: Nunito, var(--font--body), sans-serif;
  font-size: 18px;
  font-weight: 800;
  line-height: 1.2;
}
.hub__src--paper .s { font-size: 13px; font-weight: 600; margin-top: 8px; opacity: .72; }
.hub__src--mail {
  background: #fff;
  color: #1C1917;
  border-radius: 10px;
  box-shadow: 0 12px 28px rgba(28,25,23,.16);
  padding: 12px 14px 14px;
  border: 1px solid color-mix(in srgb, #CBB47B 35%, #e7e5e0);
}
.hub__src--mail .from {
  font-size: 11px; font-weight: 700; letter-spacing: .04em;
  text-transform: uppercase; color: #2497F0;
}
.hub__src--mail .t { font-size: 15px; font-weight: 800; margin: 4px 0 6px; line-height: 1.25; }
.hub__src--mail .s { font-size: 12px; line-height: 1.35; color: #57534E; }
.hub__src--imessage {
  background: transparent;
  box-shadow: none;
  padding: 0;
}
.hub__src--imessage .bubble {
  background: #DBECFF;
  color: #1C1917;
  border-radius: 18px 18px 18px 6px;
  padding: 12px 14px;
  box-shadow: 0 10px 22px rgba(28,25,23,.14);
}
.hub__src--imessage .who { font-size: 11px; font-weight: 800; color: #2497F0; margin-bottom: 4px; }
.hub__src--imessage .t { font-size: 15px; font-weight: 700; line-height: 1.3; }
.hub__src--imessage .s { font-size: 12px; margin-top: 6px; opacity: .65; }
.hub__src--app {
  background: #EFF8FF;
  color: #1C1917;
  border-radius: 16px;
  padding: 12px 14px;
  box-shadow: 0 12px 28px rgba(28,25,23,.16);
  border: 1px solid #DCF0FE;
}
.hub__src--app .app { font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: #2497F0; }
.hub__src--app .t { font-size: 16px; font-weight: 800; margin: 4px 0; }
.hub__src--app .s { font-size: 12px; font-weight: 600; color: #2497F0; }
.hub__src--list-sticky {
  background: #FFEEB8;
  color: #87723D;
  padding: 14px 14px 12px;
  border-radius: 2px;
  box-shadow: 0 10px 22px rgba(28,25,23,.16);
}
.hub__src--list-sticky .t {
  font-family: Lora, var(--font--primary), serif;
  font-size: 16px; font-weight: 700; margin-bottom: 8px;
}
.hub__src--list-paper {
  background: #F4F0E5;
  color: #1C1917;
  padding: 16px 16px 12px;
  border-radius: 3px;
  box-shadow: 0 12px 24px rgba(28,25,23,.15);
  background-image: repeating-linear-gradient(
    180deg,
    transparent 0 22px,
    color-mix(in srgb, #CBB47B 35%, transparent) 22px 23px
  );
}
.hub__src--list-paper .t {
  font-family: Lora, var(--font--primary), serif;
  font-size: 16px; font-weight: 700; margin-bottom: 8px;
}
.hub__src .it { display: flex; flex-direction: column; gap: 6px; }
.hub__src .row { font-size: 13px; font-weight: 600; line-height: 1.25; }

.hub__nestie {
  position: absolute;
  right: 2.2%;
  bottom: 3.6%;
  width: 8%;
  aspect-ratio: 1;
  z-index: 6;
  opacity: 0;
  pointer-events: none;
  filter: drop-shadow(0 10px 16px rgba(28,25,23,.22));
  transition: opacity .35s ease;
}
.hub__wrap.is-nestie .hub__nestie { opacity: 1; }
.hub__nestie canvas,
.hub__nestie img {
  width: 100%; height: 100%;
  display: block;
  object-fit: contain;
}

.hub__fx {
  position: absolute;
  inset: -22% -28%;
  z-index: 5;
  pointer-events: none;
  opacity: 0;
}
.hub__wrap.is-fx .hub__fx { opacity: 1; }

.hub__habits {
  display: none;
  position: absolute;
  inset: 0;
  z-index: 7;
  pointer-events: none;
  opacity: 0;
}
.hub__wrap.is-habits .hub__habits { opacity: 1; }
.hub__habit {
  position: absolute;
  width: 210px;
  padding: 14px 16px 14px;
  border-radius: 18px;
  box-shadow: 0 16px 32px rgba(28,25,23,.16);
  font-family: Nunito, var(--font--body), sans-serif;
}
.hub__habit .nm { font-size: 16px; font-weight: 800; }
.hub__habit .lv {
  display: flex; align-items: center; justify-content: space-between;
  gap: 8px; margin: 6px 0 10px;
  font-size: 13px; font-weight: 700;
}
.hub__habit .star-row { letter-spacing: 1px; line-height: 1; }
.hub__habit .bal { white-space: nowrap; }
.hub__habit .customs { display: flex; flex-direction: column; gap: 6px; }
.hub__habit .chip {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 8px;
  border-radius: 10px;
  background: rgba(255,255,255,.45);
  font-size: 12px; font-weight: 700; line-height: 1.2;
}
.hub__habit .chip .em { font-size: 18px; line-height: 1; }

.hub.is-docked .hub__nestie,
.hub.is-docked .hub__fx,
.hub.is-docked .hub__habits { opacity: 0 !important; }
.hub__fly-card.is-meal {
  position: relative;
  padding: 0;
  overflow: hidden;
}
.hub__fly-card.is-meal img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.hub__fly-card.is-meal .cap {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 18px 10px 8px;
  color: #fff;
  font-weight: 700;
  line-height: 1.2;
  background: linear-gradient(180deg, transparent, rgba(28,25,23,.88));
}

/* ---------- the rail ---------- */
.hub__rail {
  flex: none;
  width: 4.4em;
  background: var(--nes-cream);
  border-right: 1px solid color-mix(in srgb, var(--nes-tan) 55%, transparent);
  display: flex; flex-direction: column; align-items: center;
  gap: .35em; padding: 1.5em 0;
}
.hub__rail button {
  width: 2.6em; height: 2.6em;
  display: grid; place-items: center;
  border: 0; padding: 0; border-radius: .85em;
  background: transparent; color: var(--nes-muted);
  opacity: .55; cursor: default;
  transition: opacity .45s ease, background-color .45s ease, color .45s ease;
}
.hub__rail button svg { width: 1.35em; height: 1.35em; }
.hub__rail button.is-hot {
  opacity: 1;
  color: var(--nes-blue);
  background: color-mix(in srgb, var(--nes-sky) 75%, transparent);
}

/* ---------- the scenes ---------- */
.hub__app { position: relative; flex: 1; min-width: 0; }
.hub__scene {
  position: absolute; inset: 0;
  padding: 1.7em 2em 1.9em;
  display: flex; flex-direction: column;
  opacity: 0;
  transition: opacity .5s ease;
  pointer-events: none;
}
.hub__scene.is-active { opacity: 1; }

.hub__kicker {
  margin: 0 0 1.05em;
  font-family: var(--font--ui);
  font-size: .8em; font-weight: 700;
  letter-spacing: .13em; text-transform: uppercase;
  color: var(--nes-muted);
  display: flex; align-items: center; justify-content: space-between; gap: 1em;
  flex: none;
}

/* ---------- scene 1 · the week ----------
   The bubble sits in a band of its own: the columns stop above it
   rather than running under it, so nothing is ever covered. */
.hub__scene[data-scene="0"] { padding-bottom: 4.4em; }
.hub__week { display: grid; grid-template-columns: repeat(7, 1fr); gap: .5em; flex: 1; min-height: 0; }
.hub__day {
  background: var(--nes-sand);
  border-radius: .8em;
  padding: .65em .55em;
  display: flex; flex-direction: column; gap: .38em;
  min-width: 0;
}
.hub__day > b {
  font-family: var(--font--ui);
  font-size: .78em; font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__evt {
  background: var(--nes-sky);
  color: var(--nes-sky-up);
  border-radius: .5em;
  padding: .4em .5em;
  font-size: .76em; font-weight: 600; line-height: 1.3;
}
/* the day you are standing in. Without it a week view is seven equal
   columns and the eye has nowhere to start. */
.hub__day.is-today {
  background: var(--nes-white);
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--nes-blue) 40%, transparent);
}
.hub__day.is-today > b { color: var(--nes-blue); }
/* an empty column should say so — a blank one reads as unloaded */
.hub__free { font-size: .74em; color: var(--nes-muted); }

/* the card the spoken line turns into.
   🔴 It lives in Friday from the start and FLIES IN FROM the bubble —
   the other way round (park it on the bubble, animate to a percentage)
   cannot land on the column, because the target moves with the grid at
   every width. hub.js measures the two boxes and hands over the offset,
   so the card starts on the words that made it and ends exactly where
   it belongs. */
.hub__fly {
  background: var(--nes-blue);
  color: #fff;
  border-radius: .5em;
  padding: .4em .5em;
  font-size: .76em; font-weight: 600; line-height: 1.3;
  box-shadow: 0 10px 24px -10px color-mix(in srgb, var(--nes-blue) 75%, transparent);
  opacity: 0;
}
.hub__scene.is-active .hub__fly {
  animation: hub-fly 1.15s 1.5s var(--ease-out-quint) both;
}
@keyframes hub-fly {
  0%   { opacity: 0; transform: translate(var(--fx, 0px), var(--fy, 0px)); }
  14%  { opacity: 1; transform: translate(var(--fx, 0px), var(--fy, 0px)); }
  100% { opacity: 1; transform: none; }
}

/* the voice line, spoken from across the room */
.hub__voice {
  position: absolute; left: 50%; bottom: 1.5em;
  transform: translateX(-50%);
  display: flex; align-items: center; gap: .7em;
  background: var(--nes-ink); color: var(--nes-white);
  border-radius: 999px; padding: .62em 1.15em;
  font-size: .88em; font-weight: 500; white-space: nowrap;
  min-height: 2.6em;
}
/* a level meter, not a microphone glyph: it is the one thing on the
   screen that shows the device is listening rather than displaying */
.hub__level { display: flex; align-items: center; gap: .17em; height: 1em; }
.hub__level i {
  width: .17em; height: 100%;
  border-radius: 999px;
  background: var(--bead-blue);
  transform: scaleY(.3); transform-origin: center;
}
.hub__scene.is-active .hub__level i { animation: hub-level .9s ease-in-out infinite; }
.hub__scene.is-active .hub__level i:nth-child(2) { animation-delay: .12s; }
.hub__scene.is-active .hub__level i:nth-child(3) { animation-delay: .26s; }
.hub__scene.is-active .hub__level i:nth-child(4) { animation-delay: .08s; }
@keyframes hub-level { 50% { transform: scaleY(1); } }

/* ---------- scene 2 · today's tasks ---------- */
.hub__cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75em; flex: 1; min-height: 0; }
.hub__col {
  background: var(--nes-sand);
  border-radius: .9em;
  padding: .8em;
  display: flex; flex-direction: column;
}
.hub__who {
  display: flex; align-items: center; gap: .5em;
  font-size: .88em; font-weight: 600; margin-bottom: .55em;
}
.hub__ava {
  width: 1.7em; height: 1.7em; border-radius: 50%;
  display: grid; place-items: center;
  font-size: .82em; font-weight: 700; color: var(--nes-ink);
  background: var(--nes-sky);
}
.hub__ava.is-amber { background: var(--nes-amber); }
.hub__ava.is-tan   { background: var(--nes-tan); }

.hub__task {
  display: flex; align-items: center; gap: .55em;
  background: var(--nes-white);
  border-radius: .55em;
  padding: .5em .65em;
  margin-top: .4em;
  font-size: .82em; font-weight: 500; line-height: 1.25;
}
.hub__box {
  position: relative; flex: none;
  width: 1.15em; height: 1.15em;
  border: 1.5px solid color-mix(in srgb, var(--nes-blue) 55%, transparent);
  border-radius: .35em;
}
.hub__box::after {
  content: ""; position: absolute; inset: -1.5px;
  border-radius: .35em;
  background: var(--nes-blue) no-repeat center / .7em .7em;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12.5 9.5 18 20 6.5'/%3E%3C/svg%3E");
  transform: scale(0);
}
.hub__task.is-checked .hub__box::after { transform: scale(1); }
.hub__scene.is-active .hub__task.is-done .hub__box::after {
  animation: hub-tick .38s 1.15s var(--ease-out-quint) forwards;
}
@keyframes hub-tick { to { transform: scale(1); } }

/* the tally at the foot of each column — it is what fills the card
   below two or three chores, and it is the thing a child looks at */
.hub__tally {
  margin-top: auto; padding-top: .8em;
  display: flex; align-items: center; gap: .55em;
  font-family: var(--font--ui);
  font-size: .74em; font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__bar {
  flex: 1; height: .38em; border-radius: 999px;
  background: color-mix(in srgb, var(--nes-tan) 70%, transparent);
  overflow: hidden;
}
.hub__bar i {
  display: block; height: 100%; width: var(--done, 0%);
  border-radius: 999px; background: var(--nes-blue);
  transition: width .6s var(--ease-out-quint) 1.3s;
}

/* the star the finished chore is worth, flying to the jar */
.hub__star {
  position: absolute; left: 13%; top: 33%;
  color: var(--nes-amber);
  opacity: 0;
}
.hub__star svg { width: 1.4em; height: 1.4em; }
.hub__scene.is-active .hub__star { animation: hub-star 1.15s 1.45s var(--ease-out-quint) forwards; }
@keyframes hub-star {
  0%   { opacity: 0; transform: scale(.4); }
  22%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; left: 93%; top: -1.5%; transform: scale(.7); }
}
.hub__jar {
  display: inline-flex; align-items: center; gap: .35em;
  color: var(--nes-ink);
  font-family: var(--font--body);
  font-size: 1.05em; font-weight: 600; letter-spacing: 0;
  text-transform: none;
}
.hub__jar svg { width: 1em; height: 1em; color: var(--nes-amber); }

/* ---------- scene 3 · the week's meals ---------- */
.hub__meals { display: grid; grid-template-columns: repeat(4, 1fr); gap: .65em; flex: none; }
.hub__meal {
  background: var(--nes-sand);
  border-radius: .8em;
  padding: .7em .75em;
}
.hub__meal small {
  font-family: var(--font--ui);
  font-size: .72em; font-weight: 700; letter-spacing: .11em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__meal b { display: block; font-size: .92em; font-weight: 600; margin: .25em 0 .14em; }
.hub__meal span { font-size: .76em; color: var(--nes-muted); }
.hub__meal.is-picked {
  background: var(--nes-sky);
  box-shadow: inset 0 0 0 1.5px var(--nes-blue);
}

/* the picked night, opened out: what it is, and what it costs the list */
.hub__cook {
  display: grid; grid-template-columns: 1.3fr 1fr; gap: .75em;
  flex: 1; min-height: 0; margin-top: .75em;
}
.hub__card {
  background: var(--nes-sand);
  border-radius: .9em;
  padding: .9em 1em;
  min-width: 0;
}
.hub__card { display: flex; flex-direction: column; }
.hub__card > b {
  display: block; margin-bottom: .5em;
  font-family: var(--font--ui);
  font-size: .72em; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--nes-muted);
}
/* pinned to the foot of a card, so a short list does not leave the box
   looking half-loaded */
.hub__foot {
  margin-top: auto; padding-top: .9em;
  display: flex; align-items: center; gap: .5em;
  font-size: .78em; color: var(--nes-muted);
}
.hub__foot .hub__ava { font-size: .74em; }
/* a quiet divider inside a list of hours */
.hub__part {
  margin: .9em 0 .1em;
  font-family: var(--font--ui);
  font-size: .68em; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__dish {
  font-family: var(--font--primary);
  font-size: 1.7em; font-weight: 400; letter-spacing: -.02em; line-height: 1.1;
}
.hub__note { margin: .3em 0 0; font-size: .8em; color: var(--nes-muted); }
.hub__steps { margin: .8em 0 0; padding: 0; list-style: none; }
.hub__steps li {
  position: relative;
  padding: .2em 0 .2em 1.3em;
  font-size: .8em; font-weight: 500;
  counter-increment: step;
}
.hub__steps { counter-reset: step; }
.hub__steps li::before {
  content: counter(step);
  position: absolute; left: 0; top: .28em;
  width: 1.35em; height: 1.35em;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--nes-white);
  font-family: var(--font--ui);
  font-size: .72em; font-weight: 700; color: var(--nes-muted);
}

.hub__list {
  background: var(--nes-white);
  border: 1px solid color-mix(in srgb, var(--nes-tan) 70%, transparent);
  border-radius: .9em;
  padding: .85em 1em;
  min-width: 0;
}
.hub__list b {
  display: block; margin-bottom: .3em;
  font-family: var(--font--ui);
  font-size: .72em; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__list ul { margin: 0; padding: 0; }
.hub__list li {
  list-style: none;
  padding: .4em 0;
  font-size: .82em; font-weight: 500;
  border-bottom: 1px solid color-mix(in srgb, var(--nes-tan) 45%, transparent);
}
.hub__list li:last-child { border-bottom: 0; }
/* the three the recipe adds drop in one after another — they are the
   only thing on this screen that moves, so the eye goes to them */
.hub__list li.is-new { opacity: 0; }
.hub__scene.is-active .hub__list li.is-new { animation: hub-drop .45s var(--ease-out-expo) forwards; }
.hub__scene.is-active .hub__list li.is-new:nth-of-type(2) { animation-delay: 1.05s; }
.hub__scene.is-active .hub__list li.is-new:nth-of-type(3) { animation-delay: 1.27s; }
.hub__scene.is-active .hub__list li.is-new:nth-of-type(4) { animation-delay: 1.49s; }
@keyframes hub-drop {
  from { opacity: 0; transform: translateY(-.6em); }
  to   { opacity: 1; transform: none; }
}

/* ---------- scene 4 · the morning glance ---------- */
.hub__glance { display: grid; grid-template-columns: 1.35fr 1fr; gap: .8em; flex: 1; min-height: 0; }
.hub__line {
  background: var(--nes-white);
  border-radius: .55em;
  padding: .58em .7em;
  margin-top: .42em;
  font-size: .84em; font-weight: 500;
  display: flex; align-items: center; gap: .55em;
}
.hub__line i {
  width: .5em; height: .5em; border-radius: 50%;
  background: var(--nes-blue); flex: none;
}
.hub__line i.is-amber { background: var(--nes-amber); }
.hub__line i.is-sky   { background: var(--nes-sky-up); }
.hub__temp {
  font-family: var(--font--primary);
  font-size: 2.8em; font-weight: 400; letter-spacing: -.02em;
  line-height: 1.05;
}
.hub__out { display: flex; flex-direction: column; }
.hub__leave {
  margin-top: auto;
  display: flex; align-items: baseline; gap: .5em;
  font-family: var(--font--ui);
  font-size: .74em; font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--nes-muted);
}
.hub__leave b {
  font-family: var(--font--body);
  font-size: 1.5em; font-weight: 600; letter-spacing: 0;
  text-transform: none; color: var(--nes-ink);
}

/* ---------- the line under the device ---------- */
.hub__caption { display: none; }

/* ---------- what is left once it is on the wall ---------- */
.hub__close {
  /* 8svh under the pad's painted bottom edge — see measurePad() in hub.js
     for why that edge has to be measured rather than derived. The fallback
     is where the old `bottom: 6svh` put it, so a page with no JS still
     lands somewhere sane rather than at the top of the stage.

     (Was 5svh, opened to 8svh so the pill stopped reading as part of the
     device. Back to 6svh with the new room photo — the wall between the
     device and the shelf is shorter than it was, and 8 put the pill on the
     furniture. It is the smallest of the three numbers that had to give;
     see the note on .hub.is-docked .hub__pad.) */
  position: absolute; left: 0; right: 0;
  top: calc(var(--pad-bot, 82svh) + 6svh); bottom: auto;
  z-index: 4;
  display: flex; flex-direction: column; align-items: center; gap: 1.4rem;
  padding: 0 max(5vw, 32px);
  text-align: center;
}

/* sits ON the title's slot — see the note on .hub__head. Centred on the
   slot's first line so the two swap in place rather than one arriving
   lower than the other left. */
.hub__thesis {
  /* 🔴 top: 0, and it is allowed to overflow the slot downwards. Measured
     both ways: the slot is one line tall (the title's) and this line is
     two, so hanging it from the slot's floor (bottom: 0) pushes its ink UP
     and opens the gap to the device to 153px, while top: 0 lets the second
     line hang below the slot and closes it to 110. The slot's height is
     untouched either way, which is what keeps the pad from moving when the
     copy swaps.

     🔴 The lift is on `top`, NOT on the transform. The transform is the
     entrance (translate(-50%, 12px) → 0) and the -50% in it is what centres
     the line — fold a resting offset into it and either the centring goes
     or the arrival does. `top` is the free axis here: the line is absolute,
     so moving it costs the slot nothing and the pad still does not move.

     -7svh, not the -4 it was set to by eye: this line only ever shows in
     the DOCKED view, so it carries most of that view's 5svh lift as well
     (see the note on .hub.is-docked .hub__pad). The title it shares the
     slot with stays where the flex column puts it, which is what the demo
     wants. It takes LESS than the full lift on purpose — the device can go
     as high as the wall allows, but this line has the fixed header above it
     and at the full -9svh its cap sat ~30px off the top of the window. */
  position: absolute; left: 50%; top: -7svh;
  transform: translate(-50%, 12px);
  /* 🔴 88vw, NOT 100%. The slot is shrink-to-fit around the TITLE, so a
     percentage here made this line's wrapping depend on how long the other
     line in the slot happens to be — shorten the title and this one wraps,
     for no reason visible from here. Caught by measurement: at the new size
     against a short title it broke to four lines and ran into the device.
     The stage's gutter is max(5vw, 32px) a side, so 88vw is the same bound
     the stage would have given it, stated where it can't drift. */
  width: max-content; max-width: 88vw;
  margin: 0;
  font-family: var(--font--primary);
  /* 🔴 Up from clamp(23px, 2.8vw, 42px). This line is the section's whole
     argument and it had been sized like a caption to the device under it.
     It can take the room: the slot's HEIGHT is the title's and does not
     move (see the note on top: 0 above), so growing this only lengthens the
     overhang below — the measured gap to the pad goes from 110px to ~77px
     at 1440, which is still clear air. Two authored lines, longest 17
     characters, against ~1300px of stage: width was never the constraint. */
  /* 🔴 21px FLOOR — index.html keeps the local thesis, "One screen. /
     Wherever family life happens.", 29 characters on line two against the
     branch copy's 17. At the 32px floor that line is 414px against 88vw,
     which overflows every viewport from 430px down. Re-apply after any
     future hub.css taken from the branch. */
  font-size: clamp(21px, 4.5vw, 66px);
  line-height: 1.14;
  letter-spacing: -.025em;
  /* 🔴 the contrast is made by weighting THIS line up, not by thinning the
     second one: Google Sans is served 400..700, so 400 is already as light
     as it goes — asking Google Fonts for 300 is a 400 Bad Request. */
  font-weight: 500;

  /* 🔴 ONE transform, and it keeps the -50%. There were two declarations
     here — `translate(-50%, 12px)` up top and a later `translateY(12px)`
     — and the later one won, so the resting state lost its horizontal
     centring. The line is `left: 50%` and absolutely positioned, so
     without the -50% it sat with its LEFT EDGE on the centre line, then
     slid half its own width leftwards the moment .is-on landed. That is
     the "why is it coming in from the left" — a lost declaration, not a
     designed entrance. */
  opacity: 0;
  transform: translate(-50%, 12px);
  transition: opacity .7s ease, transform .7s var(--ease-out-expo);
}
.hub__thesis.is-on { opacity: 1; transform: translate(-50%, 0); }

/* Typed, like scene one's "Parent Copilot": hub.js splits the line and
   the GLYPHS carry the arrival, so the block itself must sit still and
   fully opaque — otherwise the two fades multiply. This class is only
   added when the split actually happened, so the CSS above stays the
   fallback for no-GSAP and reduced-motion. */
.hub__thesis.is-typed {
  opacity: 1;
  transform: translate(-50%, 0);
  transition: none;
}

/* 🔴 The wrapper no longer fades or lifts. It used to run its own
   opacity/translateY entrance, which is a second animation on top of the
   ball opening inside it — the pill arrived twice, once sliding and once
   growing. The wrapper is now only the row; .is-on on it is the CUE the
   button reads. */
.hub__cta {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; justify-content: center;
}
/* the same pill the header wears, and now the same hover too: it carries
   .btn-disc, so the fill opens from the middle on the page's one shared
   timing instead of cross-fading its background. See the block at the
   foot of styles.css. */
/* 🔴 THE TWO STATES ARE SWAPPED against every other pill on the page: this
   one rests BLUE and goes to ink when you touch it, where the intro's and
   the header's rest cream and fill blue.

   That is not an inconsistency, it is what this button is. .btn-ball's
   closed shape is already the bead — a blue disc that grows into the pill —
   so resting blue means the bead simply STRETCHES, and the object you
   watched arrive is the object you press. The old open state repainted it
   cream halfway through that growth, which is why the arrival read as two
   things (a ball, then a different button) rather than one.

   The hover disc therefore has to leave blue: filling blue over blue is no
   feedback at all. Ink is the only other colour in this section that is not
   the room. */
.hub__btn {
  /* the shape is .btn-ball in styles.css — the same object the intro's CTA
     is, opening from the middle instead of from the left (.btn-ball--mid).
     What is the hub's own is the pair of colours above. */
  --btn-disc: var(--nes-ink);
  /* White at BOTH ends, by decision: it carries over the blue and over the
     ink, so nothing about the label changes as the disc sweeps under it —
     one thing moves, not two. (I had it inverting, ink-on-blue → white-on-
     ink, for the contrast: white on --bead-blue is ~2.6:1 against ~8:1 the
     other way. Overruled, and noted here rather than re-litigated: if the
     label ever looks thin against the blue, WEIGHT is the lever, not
     colour.) */
  color: #fff;
}
/* the cue: hub.js puts .is-on on the wrapper past CTA_AT */
.hub__cta.is-on .hub__btn { opacity: 1; scale: 1; background: var(--bead-blue); clip-path: inset(0 0 0 0 round 100px); }

@media (max-width: 900px) {
  .hub__pad { width: min(92vw, 120svh); }
  .hub__glance,
  .hub__cook { grid-template-columns: 1fr; }
  .hub.is-docked .hub__pad { --dock-s: .6; }
}

/* the device stops being a demo and is simply shown, with every scene
   already at its finished state */
@media (prefers-reduced-motion: reduce) {
  .hub__scene.is-active .hub__fly,
  .hub__scene.is-active .hub__star,
  .hub__scene.is-active .hub__level i,
  .hub__scene.is-active .hub__task.is-done .hub__box::after,
  .hub__scene.is-active .hub__list li.is-new { animation: none; }
  .hub__scene.is-active .hub__list li.is-new,
  .hub__scene.is-active .hub__fly { opacity: 1; }
  .hub__scene.is-active .hub__task.is-done .hub__box::after { transform: scale(1); }
  .hub__bar i { transition-delay: 0s; }
  .hub__pad, .hub__glow, .hub__head,
  .hub__caption, .hub__thesis, .hub__cta { transition-duration: .01s; }
}


/* ============================================================
   3D PAD + ROOM SWAP — carried across the hub swap, on request

   🔴 THE BRANCH VERSION HAS NEITHER. Its pad is an <iframe> of the live app
   and its stage has one room. Everything below came from the local build.
   The two halves compose rather than fight, because .hub__pad-3d is a
   SIBLING canvas inside .hub__pad: when it lights up it hides
   .hub__screen, whatever .hub__screen happens to contain. It hid a DOM
   calendar before; it hides the iframe now, unchanged.

   🔴 ONLY THESE RULES, AND THAT IS THE POINT. The first attempt lifted a
   CONTIGUOUS RANGE out of the local file and swept up everything sitting
   between the room layers and the bezel colours — .hub__head, .hub__title,
   .hub__pad, .hub.is-docked .hub__pad. Appended last, those local copies
   overrode the BRANCH's own versions of the same selectors, including the
   dock geometry (--dock-s, --dock-y, --bezel) that its animation is tuned
   against. Take rules by NAME here, never by range.

   🔴 THE CONTRACT WITH hub3d.js IS TWO ATTRIBUTES. It is self-driving: it
   observes .hub for `class` and `data-room`, lights .is-on once .is-docked
   lands, adds .hub--3d, and reads data-room for the rim's coat. The
   branch's hub.js already sets .is-docked; the room rotation that writes
   data-room was ported into it alongside this.
   ============================================================ */
.hub__glow::before,
.hub__glow::after {
  content: "";
  position: absolute; inset: 0;
  background-size: cover;
  background-position: center 26%;
  background-repeat: no-repeat;
  opacity: 0;
  transition: opacity 1.2s var(--ease-out);
}
.hub__glow::before { background-image: url("assets/hub-room-2.webp"); }
.hub__glow::after  { background-image: url("assets/hub-room-3.webp"); }

.hub[data-room="1"] .hub__glow::before,
.hub[data-room="2"] .hub__glow::before,
.hub[data-room="2"] .hub__glow::after { opacity: 1; }

/* 🔴 THE WRAP IS A CUT, NOT A FADE, for the layer underneath — hub.js
   writes this in the same task as the room so the two land in one style
   update. */
.hub.is-room-cut .hub__glow::before { transition-duration: 0s; }

/* the housing's rim follows the room */
.hub[data-room="1"] .hub__pad { --bezel-c: #a8b183; }
.hub[data-room="2"] .hub__pad { --bezel-c: #b78a58; }

/* 🔴 120%, and it is the canvas that is oversized, not the object — the
   soft edge needs the room. Held at opacity 0 until hub3d.js says the
   model is ready, so a failed load leaves .hub__screen showing rather
   than a blank rectangle. */
.hub__pad-3d {
  position: absolute; z-index: 3;
  left: 50%; top: 50%;
  /* 🔴 120 / 128, NOT 120 / 120, AND THE SECOND NUMBER IS THE PAD'S NEW
     ASPECT SHOWING THROUGH. hub3d.js frames the housing off the CANVAS's
     aspect and its own note says what it expects: "the pad is 16:9.6 and
     the unit is 16:9 … the canvas takes the pad's aspect". frame() solves
     for a world WIDTH — camera.z = shown / 2 / (tan(halfFov) * aspect) —
     so a wider canvas shows the same width and LESS HEIGHT, and the
     housing loses its top and bottom edges.

     The local pad was 16/9.6. The branch's .hub__wrap is 16/9, because
     that is the shape its iframe app is drawn to, and the pad now fills
     it. Both cannot be one aspect, so the canvas takes its own:

       want canvas 16/9.6 = 1.6667
       pad is      16/9   = 1.7778
       height = 120% x 1.7778 / 1.6667 = 128%

     which restores exactly the framing hub3d.js was written against while
     leaving the pad — and the app inside it — at 16/9.

     ⚠️ The 120 is NOT free to change on its own: it pairs with FILL = 1.20
     in hub3d.js, which is the overhang the soft edge needs. Move one and
     the other has to move with it, and then this height has to be
     re-derived from the ratio above. */
  width: 120%; height: 128%;
  transform: translate(-50%, -50%);
  opacity: 0;
  transition: opacity .55s var(--ease-out);
}
.hub__pad-3d.is-on { opacity: 1; }
.hub.hub--3d.is-docked .hub__pad { box-shadow: 0 46px 90px -30px color-mix(in srgb, var(--nes-ink) 55%, transparent); }
.hub__pad:has(.hub__pad-3d.is-on) .hub__screen { opacity: 0; transition: opacity .55s var(--ease-out); }

@media (prefers-reduced-motion: reduce) {
  .hub__glow::before,
  .hub__glow::after { transition-duration: .01s; }
}
