/* ═══════════════════════════════════════════════════════════
   Class Pet Club — wall / setup styles
   Themes swap the block of variables below only.

   The palette has ONE rule: **the pets are the only colour on screen.**
   Every surface here is a paper tone — no hue worth naming. That is not a
   stylistic preference, it is the product: 24 realistic 3D animals on a wall
   of tinted cards were competing with the cards, and the blue/purple level
   tints in particular fought the golden and ginger coats. Orange used to be
   the accent (buttons, level pill, progress bar); it went the same way, and
   the level is carried by the `.lvl` pill and the pet's own growth instead.

   What is deliberately still coloured: the +N/−N signal, and the four
   *moments* — max-level gold, the podium's gold/silver/bronze, the confetti,
   the Legendary title. Those are one-second events, not a background, and a
   celebration with the colour taken out is not a celebration.

   The theme names are older than the rule. They are kept as keys because
   they are in saved data and in three tests; read them as paper stocks now —
   sunrise is white, meadow cream, ocean cool grey, grove sage.
   ═══════════════════════════════════════════════════════════ */

/* Self-hosted, not from Google's CDN: this app makes no external requests at
   all, and a school network that blocks fonts.googleapis.com should not
   change how the wall looks. Latin + latin-ext (280KB of Nunito static would
   be absurd for a UI face; these two are the variable font, one file covering
   400–900 — verified, the range asset is 39KB against 16KB for a single
   weight). latin-ext is what carries Sofía and José: US rosters need it. */
@font-face{
  font-family:'Nunito';
  font-style:normal; font-weight:400 900; font-display:swap;
  src:url('fonts/nunito-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
    U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}
@font-face{
  font-family:'Nunito';
  font-style:normal; font-weight:400 900; font-display:swap;
  src:url('fonts/nunito-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
    U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
    U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root{
  --bg1:#ffffff; --bg2:#faf9f6; --bg3:#f4f2ee;
  --ink:#232019; --muted:#8b8579;
  --card:#ffffff; --card-edge:rgba(35,32,25,.09);
  --panel:rgba(255,255,255,.9);
  /* the opaque sheet at the foot of a card — the lightest surface on screen,
     because it holds the words the teacher reads */
  --sheet:color-mix(in srgb, var(--bg1) 62%, #fff);
  /* ...and the stage above it, the studio backdrop the pet stands on. It is
     darker than the sheet on purpose: a white bichon on a white card had no
     edge at all, which was the other half of what "the wall looks busy"
     meant. Every coat, from samoyed to chocolate lab, now has a ground. */
  --stage:color-mix(in srgb, var(--ink) 9%, var(--bg1));
  --accent:#232019; --accent-ink:#fff;
  --good:#15803d; --bad:#be123c;
  --bar:color-mix(in srgb, var(--ink) 12%, var(--bg1)); --bar-ink:var(--accent);
  --shadow:0 10px 24px rgba(35,30,20,.10);
  --shadow-lg:0 24px 56px rgba(35,30,20,.20);
}
[data-theme="meadow"]{
  --bg1:#f8f5ee; --bg2:#f3efe5; --bg3:#ebe6da;
  --ink:#2a251d; --muted:#8d867a;
  --card:#fffdf9; --card-edge:rgba(45,38,28,.10);
  --panel:rgba(255,253,249,.9);
  --stage:color-mix(in srgb, var(--ink) 10%, var(--bg1));
  --bar:color-mix(in srgb, var(--ink) 13%, var(--bg1));
  --shadow:0 10px 24px rgba(45,38,25,.11);
  --shadow-lg:0 24px 56px rgba(45,38,25,.21);
}
[data-theme="ocean"]{
  --bg1:#f2f4f6; --bg2:#ebeff2; --bg3:#dfe5ea;
  --ink:#1e2429; --muted:#7f8a93;
  --card:#ffffff; --card-edge:rgba(28,36,42,.10);
  --panel:rgba(255,255,255,.9);
  --stage:color-mix(in srgb, var(--ink) 10%, var(--bg1));
  --bar:color-mix(in srgb, var(--ink) 13%, var(--bg1));
  --shadow:0 10px 24px rgba(25,33,40,.11);
  --shadow-lg:0 24px 56px rgba(25,33,40,.22);
}
[data-theme="grove"]{
  /* the sage paper. Twilight occupied this slot until 2026-10-11: the studio-lit
     cover photo read badly on near-black (every cut edge and the kept floor
     shadow lit up), the user's call was to drop the dark theme outright, and a
     fourth light tint keeps every screen inside the "paper only" rule. */
  --bg1:#f2f4ee; --bg2:#ebefe4; --bg3:#e1e7d7;
  --ink:#232819; --muted:#7f8975;
  --card:#fdfff9; --card-edge:rgba(35,40,25,.10);
  --panel:rgba(253,255,249,.9);
  --stage:color-mix(in srgb, var(--ink) 10%, var(--bg1));
  --bar:color-mix(in srgb, var(--ink) 13%, var(--bg1));
  --shadow:0 10px 24px rgba(28,34,20,.11);
  --shadow-lg:0 24px 56px rgba(28,34,20,.21);
}

*{box-sizing:border-box}
/* flex/grid display rules below would otherwise beat the hidden attribute */
[hidden]{display:none !important}
html,body{height:100%}
body{
  margin:0; overflow:hidden; color:var(--ink);
  /* Nunito first, then the rounded system faces, then the plain ones. The
     order matters on Windows: it used to render in Segoe UI, which is a UI
     face rather than a classroom one, and Nunito is now shipped with the app
     (see @font-face) so it is not a request that can fail. Everything behind
     it is a first-paint fallback for the moment before the woff2 lands. */
  font-family:'Nunito',ui-rounded,"SF Pro Rounded","Segoe UI Variable Display","Segoe UI",system-ui,-apple-system,"Helvetica Neue",sans-serif;
  background:
    radial-gradient(120% 90% at 12% 0%, var(--bg2) 0%, transparent 55%),
    radial-gradient(110% 90% at 92% 8%, var(--bg3) 0%, transparent 50%),
    linear-gradient(170deg, var(--bg1) 0%, var(--bg2) 100%);
  background-attachment:fixed;
  -webkit-font-smoothing:antialiased;
}
.muted{color:var(--muted)}
.view{position:fixed; inset:0; display:flex; flex-direction:column; z-index:1}

/* ── buttons ─────────────────────────────────────────────── */
.btn{
  font:inherit; font-weight:700; border-radius:12px; cursor:pointer;
  border:1px solid transparent; padding:9px 14px; line-height:1;
  white-space:nowrap;
  transition:transform .12s ease, background .15s, opacity .15s, border-color .15s;
}
.btn:active{transform:translateY(1px) scale(.98)}
.btn.primary{background:var(--accent); color:var(--accent-ink); box-shadow:0 6px 16px color-mix(in srgb, var(--accent) 35%, transparent)}
.btn.ghost{background:transparent; color:var(--ink); border-color:var(--card-edge)}
.btn.ghost:hover{background:var(--card)}
.btn.danger{background:transparent; color:var(--bad); border-color:color-mix(in srgb, var(--bad) 35%, transparent)}
.btn.danger:hover{background:color-mix(in srgb, var(--bad) 10%, transparent)}
.btn.big{padding:13px 22px; font-size:16px; border-radius:14px}
.btn.on{background:var(--accent); color:var(--accent-ink); border-color:transparent}
/* a small pick-one row (Setup): the buttons hold their own state in `.on` */
.seg{display:flex; flex-wrap:wrap; gap:8px}

/* ── topbar ──────────────────────────────────────────────── */
.topbar{
  position:relative; z-index:3;
  flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
  gap:12px; padding:10px 16px; opacity:.28; transition:opacity .35s ease;
}
.topbar:hover,.topbar:focus-within{opacity:1}
.brand{display:flex; align-items:center; gap:8px; font-weight:800; letter-spacing:-.01em; font-size:16px; min-width:0}
/* Truncate a long class name instead of wrapping the bar onto two rows —
   the topbar is a thin strip above a full-bleed wall and has no height to spare. */
#className{white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.paw{font-size:19px}
.actions{display:flex; align-items:center; gap:8px}
.selcount{font-weight:800; font-size:14px; color:var(--accent); padding-right:4px}
/* The topbar is already at its limit on a phone — the class name truncates to
   "Class ..." before the overview button exists at all — so the word goes and
   the glyph stays, same as the trophy. The over-cap warning is a toast rather
   than a chip for the same reason: it fires at the moment of the tap, which is
   when it is worth reading, and costs the topbar nothing. */
@media (max-width:899px){
  #btnOverview .ov-txt{display:none}
}

/* ── the wall ────────────────────────────────────────────── */
/* The wall scrolls. Cards are as big as their column gives them — the pet is
   the product, and on a 24-student wall the old "fit everyone on one screen"
   rule was squeezing it to 30px on a phone, at which point every animal looks
   the same.

   The row height is NOT `auto`, tempting as that is: `.pet` is a size
   container, size containment zeroes its intrinsic height, and a row that
   asks the card how tall it is therefore gets "just the sheet" back — the
   card collapsed to 2px and the pet to nothing. `layoutGrid()` measures the
   column and the sheet and sets `grid-auto-rows` in pixels instead; `auto`
   here is only the fallback for the frames before that runs.

   `align-content:start` is not decoration: a grid's default is `stretch`, and
   stretch DOES distribute free space into rows, so without it the final row
   would inflate to fill whatever height was left over.

   The scrollbar gutter is reserved even when nothing overflows, so the column
   width does not jump the moment a class grows past one screen. */
.grid{
  flex:1 1 auto; display:grid; gap:12px;
  padding:2px 16px 16px; min-height:0;
  overflow-y:auto; overflow-x:hidden;
  grid-auto-rows:auto; align-content:start;
  scrollbar-gutter:stable;
}
/* ── the overview ────────────────────────────────────────────
   The one place the old contract survives: fit the whole class on one screen.
   It is a body class rather than a #grid class so the cramped-card container
   query further down can also react to it (a container query cannot reach an
   ancestor's class, but `.card` sits inside both). Everything here undoes the
   scrolling block above and hands the cards back to `1fr` rows, which is what
   makes them shrink instead of overflow.

   Above the cap this is allowed to be too small to enjoy — the teacher asked
   for it, and #ovHint says so in words. Refusing to draw it would leave a
   30-student class with no way to see everyone at once at all. */
body.overview .grid{
  overflow:hidden; grid-auto-rows:1fr; align-content:stretch;
}
/* ...including the card's own flex split, which is the other half of the old
   contract. On a scrolling wall the sheet is exactly as tall as its text and
   the stage takes everything else; on a fitted wall the rows are handed a
   share of the viewport that has nothing to do with how tall the pet wants to
   be, and the sheet is what absorbs the difference. Swapping them is what
   would send an overview card's sheet off the bottom of its row. */
body.overview .card .stage{flex:0 1 auto}
body.overview .card .meta{flex:1 1 auto}
/* The feed and selection bars are fixed to the bottom of the viewport, so on a
   scrolling wall the last row would finish underneath them. Give the scroll
   container back their height while they are up. */
body:has(#selbar:not([hidden])) .grid,
body:has(#hatchbar:not([hidden])) .grid{padding-bottom:84px}
/* Two bands, not an avatar circle: a stage up top where the pet stands
   (and fills its full width, so the breed still reads from the back of
   the room), and a sheet of paper below holding who it is. The stage is
   a shade darker than the sheet — the two tones are the whole structure
   now, and they are doing the job the level tint used to do badly. */
.card{
  /* A size container, so the cramped-card rules further down can react to
     THIS card's height. The viewport is the wrong signal for that: a short
     wide window cramps a card exactly as hard as a phone does, and a tall
     phone with 12 students does not cramp one at all. Nothing inside `.pet`
     is affected — `.pet` is its own container and stays the nearest one for
     every cq unit in the file.
     Named, not anonymous: `.pet` is a container too, so an unnamed
     `@container (max-height:…)` reached from `.breed` would resolve against
     the 148px-tall pet instead of the card and fire on a desktop. The name
     pins every query to the card. */
  container:card / size;
  position:relative; display:flex; flex-direction:column;
  border:1px solid var(--card-edge); border-radius:20px;
  box-shadow:var(--shadow); cursor:pointer; overflow:hidden; min-height:0;
  background:var(--card);
  transition:transform .16s ease, box-shadow .2s ease, border-color .2s ease;
}
.card:hover{transform:translateY(-3px); box-shadow:var(--shadow-lg)}
.card:active{transform:scale(.985)}

/* On a scrolling wall the card's height is the pet plus the sheet and nothing
   else, so the split is: the sheet takes exactly the height of its own text,
   the stage takes the rest. The stage MUST grow — the pet is size-contained
   and asks for no height of its own, so with a `flex-grow:0` stage the sheet's
   grow wins the argument, the sheet swells to fill the card, and the pet gets
   the leftovers (measured: stage 230 against a 251 square, art 198 instead of
   251 — a quarter of the picture thrown away at every card size).
   `min-height:0` still lets the stage give height back when a row comes out
   short; overview mode swaps both of these back, see the overview block. */
/* The studio backdrop. It is the ONE thing on this card that is not the same
   paper as everything else, and it earns its keep: a white bichon on a
   near-white card had no visible edge at all (the dark-pet half of that
   problem left with twilight, the retired dark theme).
   The horizontal padding and border are load-bearing: `layoutGrid()` in
   app.js hard-codes `artW = cardW - 18` (1px card border + 8px stage padding
   each side) and `+8` for the top padding, and `.pet` is a size container
   calibrated against exactly that interior. Nothing may be added
   horizontally — no border, no inset, no `overflow:hidden`, no
   `filter`/`transform`/`contain`.
   The 24px BOTTOM strip is the one vertical addition, and it is synced: the
   breed caption hangs from the art box's bottom edge, and that edge used to
   sit at the divider — the label floated on the sheet and crowded the name
   row (品种名上移，2026-10-12). The strip is stage ground for the caption to
   stand on, above the divider, and `layoutGrid()` adds the same 24px to the
   row height so the sheet keeps every pixel it had. Change one and change
   the other. */
.card .stage{
  position:relative; flex:1 1 auto; min-height:0;
  display:flex; flex-direction:column; align-items:center;
  padding:8px 8px 24px; background:var(--stage);
}

/* ── the pet rig ─────────────────────────────────────────────
   Nested layers, one transform job each, so the level scale, the
   idle breath and the one-shot motions never clobber each other
   (an element only has one `transform`):
     .body  → a SQUARE the exact size of the rendered art, plus
              the level scale (--s). Square matters: everything
              below measures in %, so it must measure against the
              picture, not against a letterboxed card slot.
     .shadow→ ground contact shadow, anchored to the art box
     .rig   → idle breathing, per-card phase via --d
     .actor → one-shot motions (nibble / hop / micro-*)
   ─────────────────────────────────────────────────────────── */
/* width:100% + aspect-ratio gives .pet a definite height, which is what
   container-type:size needs to resolve the cq units inside .body.
   The square is handed in as --art rather than left to aspect-ratio, because
   on a wall that scrolls it never got the chance to resolve: size containment
   makes .pet's intrinsic height 0, so when a row is not already tall enough
   the flex shrink takes the pet down with it (measured 251x0). JS knows the
   column width, so it knows the square. `auto` is the fallback everywhere JS
   has not spoken — overview mode clears the variable and relies on this. */
.card .pet{flex:0 1 auto; width:100%; height:var(--art,auto); aspect-ratio:1; min-height:0; position:relative; container-type:size}
/* The breed tag is pinned to the bottom edge of the ART BOX, not to the
   bottom of the stage — anchored to the stage it drifts a hundred pixels
   away from the pet on a short wide card. It has to be a child of `.pet`
   for the cq units to resolve. What sits under its anchor line is now the
   stage's 24px bottom strip (see `.stage` above), so the caption stands on
   stage ground, clear of the divider and the name row below it.
   The line is deliberately level: no *var(--s) term. Following the level
   scale made every caption sit at its own animal's bottom, so a Lv.1 and
   a Lv.10 pet never lined up across the wall (the user's read: 品种名字
   不在一个水平面，影响美观). A caption row is a caption row — one line
   for the whole wall, pets of every size grow above it. */
.card .breed{
  position:absolute; left:8px; right:8px; z-index:3; text-align:center;
  top:calc(50% + min(50cqw, 50cqh)); padding-top:5px;
  font-size:clamp(9px,.72vw,12px); font-weight:600; letter-spacing:.01em; color:var(--muted);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.card .body{
  position:absolute; inset:0; margin:auto;
  /* min(cqw,cqh) = exactly what `object-fit:contain` gives the square art.
     (aspect-ratio alone can't do this: with a definite width it won't
     transfer a max-height clamp back into the width.) */
  width:min(100cqw,100cqh); aspect-ratio:1;
  transform:scale(var(--s,.8));
  transition:transform .55s cubic-bezier(.34,1.56,.64,1);
}
.card .rig,.card .actor{position:absolute; inset:0; display:flex; align-items:center; justify-content:center}
/* Two idle animations on ONE element — which only works because they touch
   different properties. `breathe` drives translate/scale, `sway` drives
   rotate, and the browser composes translate → rotate → scale by itself.
   Written as one `transform` they would overwrite each other and the pet
   would visibly pick one. Both clocks come from the build (see BUILDS). */
.card .rig{
  transform-origin:50% 86%;                     /* lean about the hips, not the middle */
  animation:
    breathe var(--mBreath,4.4s) ease-in-out infinite,
    sway    var(--mSway,8s)     ease-in-out infinite;
  animation-delay:var(--d,0s), var(--d2,0s);
}
.card .actor{transform-origin:50% 90%}          /* squash pivots at the feet */
.card .pet img{position:absolute; inset:0; width:100%; height:100%; object-fit:contain}
/* cqmin, not vw — the placeholder is the fallback that stands in for the pet,
   so it has to be sized FOR the pet. It was clamp(34px,4.6vw,64px), which is a
   glyph for the window: on a 256px art box (a 5-column desktop wall, now that
   the wall scrolls) 4.6vw tops out at the 64px ceiling and the emoji sits in a
   189px circle looking lost. 34% of the art box is roughly what 4.6vw looked
   like at the sizes this used to run at. `.pet` is the container and
   min(100cqw,100cqh) of it is exactly the art box, so cqmin is the pet — same
   reasoning as the badge below. The 110px ceiling keeps a fullscreen wall sane. */
.card .ph{position:absolute; font-size:clamp(18px,34cqmin,110px); line-height:1}

/* ── the badge ───────────────────────────────────────────────
   A pet that reaches the top of the ladder gets a gold-rimmed shield stamped
   into the corner of its card. It replaces the crown, and the reason is worth
   keeping: a crown had to be WORN, so it had to be anchored to each pet's
   skull. Measured, the centre-line forehead runs from 6.9% of the art box
   (labrador) to 15.4% (corgi, frenchie) — 21px of travel at 251px — so one
   offset could never fit all twelve, and the fix was a per-pet measurement
   that had to be redone on every art change. A badge is not worn by the
   animal, it is awarded to it, and it hangs off the CARD. Nothing in this
   block measures a pet, and nothing in it has to be re-measured if the art
   changes.

   The face used to hold the pet's own photograph. It holds the collection
   COUNT now — how many badges this student has, retired ones plus the one
   being worn — which is the same number the roster row and the student page
   report, so the shield can no longer disagree with them. A count also does
   the one thing the photo could not: it climbs. Six rungs (see `rungOf` in
   app.js) dress the shield up as the collection grows — a fringed ring
   behind the rim at 3, a champagne face at 5, a deeper rim at 7, an
   ink-brown ribbon at 9, static twinkles at 12 — so a long-running class
   can see the collection at a glance without opening anything.

   Everything is drawn: no image, no font, no SVG. The gold rim is the outer
   shield's `clip-path` with the paper face inset by `--rim`, so what shows
   between them is the rim. The tier pieces are the same idea — one more
   clipped shape behind (the ring), a background on the face, one `data-tier`
   switch per level, all measured off `--bw`. `.shield` clips its own
   children, which is what lets the sheen sweep past the edges without a
   second clip on it.

   `--bw` and not a bare cq unit, because the SAME `.badge` is drawn on a
   card, on a roster row and in the ceremony, and `cqw` would resolve against
   three different rulers — a card, nothing, and the viewport. Only the card
   sets one. This is the "crown for the window" bug from 2026-10-10, twice
   fixed and worth not repeating: a vw-sized crown held 34px from a 325px art
   box down to a 147px one, so on a 24-student wall it covered the top third
   of the animal, straight over the eyes. A decoration never sizes itself off
   the viewport. */
.badge{
  --bw:40px; --rim:calc(var(--bw) * .105);
  /* one shape, three consumers — the rim, the face and the ring all cut the
     same shield or the ring comes out lopsided */
  --shield:polygon(50% 0, 100% 13%, 100% 57%, 50% 100%, 0 57%, 0 13%);
  position:relative; display:inline-block; pointer-events:none;
  width:var(--bw); height:calc(var(--bw) * 1.16);
}
/* Four stops, not two: the rim has to read as struck metal, and metal is
   light-then-shadow. The near-white top stop and the amber shoulder at 86%
   are what make it look raised; a flat yellow-orange ramp (the first version)
   read as a sticker. */
.badge .shield{
  position:absolute; z-index:2; inset:0 0 auto 0; height:84%;
  clip-path:var(--shield);
  background:linear-gradient(158deg,
    #fff3ad 0%, #fcd34d 20%, #fbbf24 42%, #f59e0b 68%, #d97706 88%, #fbbf24 100%);
}
.badge .shield::before{
  content:''; position:absolute; inset:var(--rim); background:var(--sheet);
  clip-path:var(--shield);
}
/* The face is the count. The window carries the shield shape as well as
   `overflow:hidden`, so its background ends on the same edge line the gold
   rim does; a plain rectangle here would leave a wedge of bare gold either
   side of the point.
   The numeral is grid-centred and then nudged UP by a tenth of `--bw`: the
   shield tapers to a point, so its optical centre sits above its geometric
   one, and an unpadded number reads as having slid down the shield. The
   bronze on near-white is the same contrast the rim uses, one step darker. */
.badge .face{
  position:absolute; inset:calc(var(--rim) * 1.35);
  clip-path:var(--shield); overflow:hidden;
  display:grid; place-items:center;
  font-weight:900; line-height:1; font-variant-numeric:tabular-nums;
  color:#8a5a09;
  font-size:calc(var(--bw) * .46);
  padding-bottom:calc(var(--bw) * .10);
}
/* two digits, or they push the shield's shoulders apart — "12" at .46em is
   wider than the face is wide at its narrowest */
.badge.wide .face{font-size:calc(var(--bw) * .30)}
/* The ring: one more shield behind the shield, a shade bigger, so what shows
   around the rim is a fringe of pale gold. `z-index:2` and not 1 — the ribbon
   is a positioned sibling at 1, and at equal z the tree order would put the
   fringe UNDER the ribbon's notch, which reads as the ribbon being pasted on
   top. At 2 the tree order does the work: ring, then shield, both over the
   ribbon. Sized against the SHIELD (84% of the badge tall), not the badge,
   because that is the shape it is outlining: 118% of the shield's width,
   centred on the shield's centre, hence the negative offsets. */
.badge .ring{
  display:none;
  position:absolute; z-index:2; left:-9%; top:-7.55%; width:118%; height:99.1%;
  clip-path:var(--shield);
  background:linear-gradient(158deg, #fffbe8 0%, #fde68a 45%, #f6cf4e 100%);
}
.badge[data-tier="2"] .ring,
.badge[data-tier="3"] .ring,
.badge[data-tier="4"] .ring,
.badge[data-tier="5"] .ring,
.badge[data-tier="6"] .ring{display:block}
/* rung 3: a warmer paper behind the number */
.badge[data-tier="3"] .face,
.badge[data-tier="4"] .face,
.badge[data-tier="5"] .face,
.badge[data-tier="6"] .face{
  background:linear-gradient(180deg, #fff8e2 0%, #ffe9b0 100%);
}
/* rung 4: the fringe deepens to true amber. The far stop is the one that
   carries — the near stop only shows at the very top of the shield. */
.badge[data-tier="4"] .ring,
.badge[data-tier="5"] .ring,
.badge[data-tier="6"] .ring{
  background:linear-gradient(158deg, #fde68a 0%, #f0b23c 40%, #c2700a 100%);
}
/* rung 5: the ribbon goes ink-brown — the one flat colour on the badge, so it
   reads as the deep end of the same metal rather than a second decoration */
.badge[data-tier="5"]::after,
.badge[data-tier="6"]::after{
  background:linear-gradient(180deg, #8a5a09, #6b4406);
}
/* rung 6: the twinkles stop being an event and become part of the badge.
   Static here — the wall is not where 24 cards get 48 animated sparkles; the
   ones that animate are the maxed card's own, above. */
.badge[data-tier="6"] .tw{opacity:.9}
.badge[data-tier="6"] .tw-b{opacity:.7}
/* the sheen. Parked off the shield at rest, so `animation:none` (the teacher's
   motion switch, or the OS preference) leaves it invisible rather than frozen
   mid-sweep. */
.badge .shield::after{
  content:''; position:absolute; inset:-30%;
  background:linear-gradient(105deg, transparent 42%, rgba(255,255,255,.92) 50%, transparent 58%);
  transform:translateX(-135%);
}
@keyframes badgeSheen{0%,64%{transform:translateX(-135%)} 96%,100%{transform:translateX(135%)}}
/* the glow — the badge's own light, and the one thing a gold decoration on a
   pale card needs: without it the shield sits ON the card, with it the shield
   is lit ABOVE the card. Kept faint on purpose (a wall of 24 maxed pets is 24
   of these at once) and slow enough to read as alive rather than blinking.
   `::before` is the only free slot — `.badge::after` is the ribbon. */
.badge::before{
  content:''; position:absolute; z-index:0; inset:-18%; border-radius:50%;
  background:radial-gradient(closest-side,
    rgba(253,224,71,.85), rgba(251,191,36,.32) 52%, rgba(251,191,36,0) 78%);
  opacity:0;
}
/* the ribbon: a notch-tailed band that reads as coming out from under the
   shield's point. Pure decoration — the level is already in the pill.
   It stays the FLATTER gold of the two, deliberately: the version that made it
   darker than the shield (trying to separate them) put the strongest colour on
   the smaller shape, and the ribbon started reading as the badge. The shield
   already carries a dark shoulder at its point, which is separation enough. */
.badge::after{
  content:''; position:absolute; z-index:1; left:50%; bottom:0; width:46%; height:32%;
  transform:translateX(-50%);
  background:linear-gradient(180deg, #fbbf24, #f59e0b);
  clip-path:polygon(0 0, 100% 0, 100% 100%, 50% 58%, 0 100%);
}
.badge .tw{
  position:absolute; z-index:3; font-style:normal; line-height:1; opacity:0;
  font-size:calc(var(--bw) * .42); color:#fffdf0;
  text-shadow:0 0 5px #fde047, 0 0 10px #fbbf24;
}
.badge .tw-a{top:-8%; right:-10%}
.badge .tw-b{bottom:8%; left:-12%; font-size:calc(var(--bw) * .28)}
@keyframes twinkle{
  0%,58%,100%{opacity:0; transform:scale(.3) rotate(0deg)}
  68%{opacity:1; transform:scale(1.2) rotate(22deg)}
  84%{opacity:.5; transform:scale(.9) rotate(40deg)}
}

/* The roster row's quiet count — the same shield, flattened to ink-on-card.
   The list stays a list of names: the count reads as one more fact in the
   row's own voice (the Lv. tag's family) rather than a second gold object
   beside a name, and the gold economies (glow, ribbon, twinkle, ring)
   belong to the wall, where the pet is. */
.badge.mute .shield{background:color-mix(in srgb, var(--ink) 15%, var(--card))}
.badge.mute .shield::before{background:var(--card)}
.badge.mute .face{color:var(--muted)}
.badge.mute .shield::after,
.badge.mute .ring, .badge.mute .tw{display:none}
.badge.mute::before, .badge.mute::after{display:none}

/* On the card: the stage's top-left, mirrored across from the level pill.
   20% of the card's width with a floor and a ceiling — the shield has to be
   big enough to read "12" at 24 students on a phone and small enough not to
   be the largest thing on a 30-student overview card. */
.card .badge{
  --bw:clamp(20px, 20cqw, 54px);
  position:absolute; top:9px; left:9px; z-index:5;
  opacity:0; transform:scale(.45) rotate(-14deg);
  filter:drop-shadow(0 3px 6px rgba(90,54,0,.34));
}
/* Up whenever the student holds a badge. The held count includes the retired
   ones, which is what keeps the shield on the card across the award — the
   gold border and the stamp retire there, the collection does not. */
.card.badged .badge{opacity:1; transform:none}
/* the stamp, and only for the pet standing on the top rung right now. It
   arrives oversized and lands hard, which is what a seal does — a pop from
   small reads as a sticker appearing. `both` so the resting state is the
   animation's last frame; that also means a plain `opacity:0` cannot override
   it, which is why the blind box below resets the animation instead (see
   body.eggs). No fill for the resting state here: the rule above carries it,
   and it is the one that survives `animation:none`. */
.card.max .badge{animation:badgeStamp .66s cubic-bezier(.34,1.56,.64,1) both}
@keyframes badgeStamp{
  0%  {opacity:0; transform:scale(1.9) rotate(-20deg)}
  45% {opacity:1; transform:scale(.92) rotate(6deg)}
  70% {          transform:scale(1.05) rotate(-3deg)}
  100%{opacity:1; transform:scale(1) rotate(0deg)}
}
/* only a maxed card animates. Every one of these is opacity or transform, so
   they ride the compositor; a wall of 24 ordinary cards carries none of them,
   and a wall of 24 maxed ones carries 72 — which is the price of the badge
   being the reward, and it is paid in GPU layers, not in layout. */
.card.max .badge::before{animation:badgeGlow 3.4s ease-in-out .55s infinite}
@keyframes badgeGlow{
  0%,100%{opacity:.30; transform:scale(.93)}
  50%    {opacity:.62; transform:scale(1.04)}
}
.card.max .badge .shield::after{animation:badgeSheen 4.4s ease-in-out 1s infinite}
.card.max .badge .tw{animation:twinkle 2.8s ease-in-out infinite}
.card.max .badge .tw-b{animation-delay:1.4s}

/* ground shadow — makes a hop read as a hop, not a resize.
   Neutral, not the old warm brown: on a grey stage a brown ellipse reads as a
   stain rather than a shadow. (`_test_motion.html` asserts this stays a
   radial-gradient — a flat fill would give the hop a hard edge.) */
.card .shadow{
  position:absolute; left:50%; bottom:4%; width:54%; height:9%; z-index:0;
  transform:translateX(-50%); border-radius:50%; pointer-events:none;
  background:radial-gradient(ellipse at 50% 50%, rgba(24,22,18,.34), rgba(24,22,18,0) 72%);
  opacity:.5;
}

/* idle: every pet breathes, each on its own clock and at its own depth.
   --mAmp is per-card; a big dog's chest moves more than a cat's. */
@keyframes breathe{
  0%,100%{translate:0 0; scale:1 1}
  50%    {translate:0 calc(-1.6% * var(--mAmp,1));
          scale:calc(1 + .014 * var(--mAmp,1)) calc(1 - .014 * var(--mAmp,1))}
}
/* idle: and leans, slowly, well out of step with its own breathing. Cats
   sway further; a Samoyed is a boulder. */
@keyframes sway{
  0%,100%{rotate:0deg}
  32%    {rotate:calc(1.1deg * var(--mSwayAmp,1))}
  68%    {rotate:calc(-0.9deg * var(--mSwayAmp,1))}
}

/* Every one-shot duration is multiplied by --mSpeed: a retriever chews and
   lands slowly, a cat flicks through the same motion in 72% of the time. */
/* one-shot: nibble (a point was given) */
.card .pet.nibble .actor{animation:nibble calc(.78s * var(--mSpeed,1)) cubic-bezier(.36,.07,.19,.97)}
@keyframes nibble{
  0%  {transform:translateY(0) scale(1,1)}
  16% {transform:translateY(4%) scale(1.10,.86)}
  34% {transform:translateY(1%) scale(1.06,.94)}
  52% {transform:translateY(-11%) scale(.93,1.10)}
  70% {transform:translateY(0) scale(1.06,.95)}
  85% {transform:translateY(-2.5%) scale(.985,1.015)}
  100%{transform:translateY(0) scale(1,1)}
}
.card .pet.nibble .shadow{animation:shadowNibble calc(.78s * var(--mSpeed,1)) ease}
@keyframes shadowNibble{
  0%,100%{transform:translateX(-50%) scale(1); opacity:.5}
  16%    {transform:translateX(-50%) scale(1.10); opacity:.6}
  52%    {transform:translateX(-50%) scale(.86); opacity:.36}
}

/* one-shot: hop (level up). --mHeft scales how far it leaves the ground —
   a Labrador clears noticeably less air than a Siamese on the same jump. */
.card .pet.hop .actor{animation:hop calc(1.05s * var(--mSpeed,1)) cubic-bezier(.3,1.3,.4,1)}
@keyframes hop{
  0%  {transform:translateY(0) rotate(0) scale(1,1)}
  14% {transform:translateY(6%) rotate(0) scale(1.14,.84)}
  34% {transform:translateY(calc(-30% * var(--mHeft,1))) rotate(-7deg) scale(.92,1.12)}
  50% {transform:translateY(calc(-34% * var(--mHeft,1))) rotate(6deg) scale(1,1)}
  68% {transform:translateY(0) rotate(0) scale(1.14,.86)}
  82% {transform:translateY(calc(-7% * var(--mHeft,1))) rotate(-3deg) scale(.97,1.04)}
  92% {transform:translateY(0) rotate(0) scale(1.04,.97)}
  100%{transform:translateY(0) rotate(0) scale(1,1)}
}
.card .pet.hop .shadow{animation:shadowHop calc(1.05s * var(--mSpeed,1)) cubic-bezier(.3,1.3,.4,1)}
@keyframes shadowHop{
  0%,100%{transform:translateX(-50%) scale(1); opacity:.5}
  34%,50%{transform:translateX(-50%) scale(.64); opacity:.24}
  68%    {transform:translateX(-50%) scale(1.08); opacity:.56}
}

/* one-shot: the treat falling in */
.card .treat{
  position:absolute; left:50%; top:32%; z-index:5; pointer-events:none;
  font-size:clamp(15px,1.6vw,24px); line-height:1;
  animation:treatDrop .72s cubic-bezier(.5,0,.6,1) forwards;
}
@keyframes treatDrop{
  0%  {opacity:0; transform:translate(-50%,-30px) scale(.6) rotate(-28deg)}
  22% {opacity:1; transform:translate(-50%,-8px)  scale(1)   rotate(8deg)}
  52% {opacity:1; transform:translate(-50%,4px)   scale(1)   rotate(0)}
  72% {opacity:1; transform:translate(-50%,4px)   scale(.8)  rotate(0)}
  100%{opacity:0; transform:translate(-50%,5px)   scale(.15) rotate(0)}
}

/* micro-motions — the wall feels alive when one pet twitches now and then */
.card .pet.micWiggle .actor{animation:micWiggle calc(.85s * var(--mSpeed,1)) ease}
@keyframes micWiggle{
  0%,100%{transform:rotate(0) translateY(0)}
  15%{transform:rotate(-5deg) translateY(-1%)}
  35%{transform:rotate(4deg)  translateY(0)}
  55%{transform:rotate(-3deg) translateY(-1%)}
  75%{transform:rotate(2deg)  translateY(0)}
}
.card .pet.micPeek .actor{animation:micPeek calc(1.3s * var(--mSpeed,1)) ease}
@keyframes micPeek{
  0%,100%{transform:rotate(0) scale(1,1)}
  25%{transform:rotate(-10deg) scale(1.02,.98)}
  62%{transform:rotate(-7deg)  scale(1.01,.99)}
}
.card .pet.micHop .actor{animation:micHop calc(.9s * var(--mSpeed,1)) cubic-bezier(.3,1.3,.4,1)}
@keyframes micHop{
  0%,100%{transform:translateY(0) scale(1,1)}
  20%{transform:translateY(3%)  scale(1.07,.93)}
  50%{transform:translateY(calc(-13% * var(--mHeft,1))) scale(.96,1.05)}
  78%{transform:translateY(0) scale(1.05,.95)}
}
.card .pet.micHop .shadow{animation:shadowMicHop calc(.9s * var(--mSpeed,1)) ease}
@keyframes shadowMicHop{
  0%,100%{transform:translateX(-50%) scale(1); opacity:.5}
  50%{transform:translateX(-50%) scale(.84); opacity:.34}
}
.card .pet.micNod .actor{animation:micNod calc(1s * var(--mSpeed,1)) ease}
@keyframes micNod{
  0%,100%{transform:translateY(0) scale(1,1)}
  30%{transform:translateY(4%) scale(1.03,.96)}
  60%{transform:translateY(-2%) scale(1,1)}
}

/* ── the egg ──────────────────────────────────────────────────
   Sibling of .body, NOT a wrapper around it: _test_motion.html asserts
   `.pet > .body > .rig > .actor` with DIRECT child selectors, so nothing
   may be inserted between those. The egg is its own layer on top.
   `display:none` unless body.eggs (the class waiting for its hatch) or
   `.card.egg` (one student whose badge deal left a successor under a shell)
   — a wall with neither pays nothing for this. */
.card .egg{
  position:absolute; inset:0; margin:auto; display:none;
  width:min(100cqw,100cqh); aspect-ratio:1; place-items:center;
  z-index:2; pointer-events:none;
  transform-origin:50% 88%;                 /* wobble pivots near the base */
  filter:drop-shadow(0 6px 9px rgba(120,88,48,.20));
  animation:eggIdle 5.2s ease-in-out infinite;
  animation-delay:var(--d,0s);              /* reuse the breathing desync */
}
body.eggs .card .egg{display:grid}
/* Both halves draw the WHOLE egg and clip to a jagged half, so the gradient
   lines up across the cut with no seam when they come apart.
   The cuts overlap by ~2% on purpose — complementary edges antialias into a
   visible hairline on a projector, overlapping opaque paint can't. */
.card .egg i{
  position:absolute; inset:0; margin:auto; width:60%; height:76%;
  border-radius:50% 50% 50% 50% / 62% 62% 38% 38%;
  background:
    radial-gradient(circle at 33% 24%, rgba(255,255,255,.98) 0 5%, rgba(255,255,255,0) 44%),
    radial-gradient(ellipse at 44% 34%, #fffdf9 0%, #fdf3e3 38%, #f4e2c6 72%, #e0c9a4 100%);
}
/* Fine teeth, not a crown: 25 small jags ±2% around the midline. Coarse ones
   read as a zigzag decoration; small ones read as a crack. */
.card .egg .top{clip-path:polygon(0 0,100% 0,100% 52%,96% 54%,92% 51%,88% 53%,84% 50%,80% 54%,76% 51%,72% 53%,68% 50%,64% 54%,60% 51%,56% 53%,52% 50%,48% 54%,44% 51%,40% 53%,36% 50%,32% 54%,28% 51%,24% 53%,20% 50%,16% 54%,12% 51%,8% 53%,4% 50%,0 52%)}
/* The lower shell is a shade darker, and because the cut is jagged the step
   reads as a hairline crack. Tuned by eye: a whole-number step (.95) reads as
   two-tone, and a drop-shadow on the silhouette is invisible at 200px. */
.card .egg .bot{
  clip-path:polygon(0 50%,4% 48%,8% 51%,12% 49%,16% 52%,20% 48%,24% 51%,28% 49%,32% 52%,36% 48%,40% 51%,44% 49%,48% 52%,52% 48%,56% 51%,60% 49%,64% 52%,68% 48%,72% 51%,76% 49%,80% 52%,84% 48%,88% 51%,92% 49%,96% 52%,100% 50%,100% 100%,0 100%);
  filter:brightness(.962);
}

/* unhatched and waiting — still alive, just not awake yet */
@keyframes eggIdle{
  0%,84%,100%{transform:rotate(0deg)}
  88%{transform:rotate(-3.2deg)}
  92%{transform:rotate(3.2deg)}
  96%{transform:rotate(-1.2deg)}
}

/* the hatch itself. --i comes from renderWall(), --stag is set on #grid so a
   30-name class doesn't take 30×90ms to finish. The same timeline serves
   both hatches: the class one (`#grid.hatching`, every card staggered) and
   the one-card one a badge egg gets when the teacher taps it (`.card.
   hatching`, --fd pinned to 0 — a single card has no running order).
   A card that already carries a badge egg is skipped by the class hatch
   (`:not(.egg)`): its egg is genuinely still shut, and re-cracking it only
   to have the shells snap back at finish would be a misfire, not a reveal. */
#grid.hatching .card:not(.egg){--fd:calc(var(--i,0) * var(--stag,80ms))}
.card.hatching{--fd:0ms}
#grid.hatching .card:not(.egg) .egg,
.card.hatching .egg{
  animation:eggShake .62s cubic-bezier(.36,.07,.19,.97) both;
  animation-delay:var(--fd);
}
@keyframes eggShake{
  0%  {transform:rotate(0deg) scale(1)}
  14% {transform:rotate(-8deg) scale(1.02)}
  30% {transform:rotate(8deg) scale(1.03)}
  46% {transform:rotate(-7deg) scale(1.045)}
  62% {transform:rotate(7deg) scale(1.06)}
  78% {transform:rotate(-4deg) scale(1.05)}
  100%{transform:rotate(0deg) scale(1.06)}
}
#grid.hatching .card:not(.egg) .egg .top,
#grid.hatching .card:not(.egg) .egg .bot,
.card.hatching .egg .top,
.card.hatching .egg .bot{
  animation-duration:.9s; animation-timing-function:cubic-bezier(.2,.7,.3,1);
  animation-fill-mode:both; animation-delay:calc(var(--fd) + .56s);
}
#grid.hatching .card:not(.egg) .egg .top,
.card.hatching .egg .top{animation-name:shellTop}
#grid.hatching .card:not(.egg) .egg .bot,
.card.hatching .egg .bot{animation-name:shellBot}
/* The shells are already 60% x 76% of the pet box, so they only need to
   travel a little — more than about half their own height and the card's
   overflow:hidden saws them off mid-flight. Shrinking sells the distance
   instead, and the parent's shake scale adds 6% on top of whatever is here. */
@keyframes shellTop{
  0%  {transform:translate(0,0) rotate(0deg) scale(1); opacity:1}
  100%{transform:translate(-30%,-44%) rotate(-42deg) scale(.78); opacity:0}
}
@keyframes shellBot{
  0%  {transform:translate(0,0) rotate(0deg) scale(1); opacity:1}
  100%{transform:translate(24%,22%) rotate(26deg) scale(.78); opacity:0}
}
/* pet grows out of the shell — .actor is safe to animate here, it carries no
   animation of its own (one-shots are opt-in via .pet classes) */
#grid.hatching .card:not(.egg) .body,
.card.hatching .body{animation:petIn .45s ease both; animation-delay:calc(var(--fd) + .70s)}
#grid.hatching .card:not(.egg) .pet .actor,
.card.hatching .pet .actor{
  animation:hatchPop .78s cubic-bezier(.34,1.56,.64,1) both;
  animation-delay:calc(var(--fd) + .70s);
}
@keyframes petIn{from{opacity:0}to{opacity:1}}
@keyframes hatchPop{
  0%  {transform:scale(.42); opacity:0}
  55% {transform:scale(1.07); opacity:1}
  100%{transform:scale(1); opacity:1}
}

/* the blind box must not leak its contents.
   The badge is a fourth way it could: it is part of the card's reveal, and
   `#btnRehatch` puts the shells back WITHOUT touching anybody's treats, so a
   wall of shields behind eggs is a state the app really reaches. The face is
   a count and not a photo any more, so nothing leaks either way — but a
   stamped shield floating over a shell is the badge arriving before the pet
   it belongs to, and the count would sit there while the card says nothing.
   `animation:none` as well as the opacity — the stamp is `fill-mode:both` and
   an animated value beats a plain declaration, so opacity alone would be
   ignored on every maxed card. Resetting it also means the stamp replays when
   the shells come off, which is the moment it should happen anyway. */
body.eggs .card .body,
body.eggs .card .breed{opacity:0}
body.eggs .card .badge{animation:none; opacity:0}

/* The same blindness, dealt per card: a badge egg, one student's successor
   waiting while the rest of the wall shows pets. The badge rule is kept even
   though the count under a shell is `collected` and would not give the
   successor away — two sources of truth for "this pet may not be shown" have
   drifted apart before in this file. The unlock is the same as the class
   hatch's: when `.egg` comes off (hatchOne) the card is `.badged` again, and
   a successor that re-reaches the top lights the stamp through `.card.max`. */
.card.egg .egg{display:grid}
.card.egg .body,
.card.egg .breed{opacity:0}
.card.egg .badge{animation:none; opacity:0}

.hatchbar{
  position:fixed; left:50%; bottom:26px; transform:translateX(-50%); z-index:46;
  padding:10px; border-radius:20px;
  background:color-mix(in srgb, var(--card) 90%, transparent);
  backdrop-filter:blur(12px);
  border:1px solid var(--card-edge); box-shadow:var(--shadow);
  animation:hatchbarIn .5s cubic-bezier(.34,1.56,.64,1);
}
.hatchbar[hidden]{display:none}
/* The drawer anchors to the same bottom edge, and it sits BELOW the bar in
   z-order (40 vs 46) — a card tapped while the eggs are up would put the
   hatch button on top of its own score list. These three own the screen
   while they are open; step aside. */
.drawer:not([hidden]) ~ .hatchbar,
#ceremony:not([hidden]) ~ .hatchbar,
.board:not([hidden]) ~ .hatchbar{display:none}
@keyframes hatchbarIn{from{opacity:0; transform:translateX(-50%) translateY(16px)}}
#btnHatch{animation:hatchPulse 2.6s ease-in-out infinite}
@keyframes hatchPulse{0%,100%{transform:scale(1)} 50%{transform:scale(1.045)}}

/* teacher can switch the whole thing off (and respect OS settings) */
body.no-motion .rig,
body.no-motion .actor,
body.no-motion .shadow,
body.no-motion .treat,
body.no-motion .egg,
body.no-motion .egg i,
body.no-motion .hatchbar,
body.no-motion #btnHatch,
body.no-motion .card.max .badge,
body.no-motion .cer-pet .badge,
body.no-motion .badge::before,
body.no-motion .badge .tw,
body.no-motion .badge .shield::after{animation:none !important}
/* `.badge::before` has `opacity:0` at rest, so killing its animation on a
   maxed card would not stop the halo, it would REMOVE it — a still halo is
   what "motion off" should mean, a missing one is a silent downgrade. */
body.no-motion .card.max .badge::before,
body.no-motion .cer-pet .badge::before{opacity:.45; transform:none}
body.no-motion .treat{display:none}
/* The badge is in both lists. Its predecessor was in neither — the crown kept
   floating on walls where every other thing had been switched off, which is
   the kind of miss that only shows up on the one machine that has the OS
   setting on. */
@media (prefers-reduced-motion:reduce){
  .rig,.actor,.shadow,.treat,.egg,.egg i,.hatchbar,#btnHatch{animation:none !important}
  .card.max .badge,.cer-pet .badge,.badge::before,.badge .tw,.badge .shield::after{animation:none !important}
  .card.max .badge::before,.cer-pet .badge::before{opacity:.45; transform:none}
  .body{transition:none}
  .treat{display:none}
}

/* The old level tint lived here: four tier colours, a wash on the top of every
   card, and a coloured glow behind Lv.7+ pets. It was meant to make level
   readable from across the room and it did the opposite — 24 tinted rectangles
   turned the wall into a colour swatch, and the blue and purple tiers sat
   directly behind golden retrievers and corgis. Level is legible without it:
   the pill says "Lv.7" in words, and the pet is visibly bigger. Nothing was
   added in its place, deliberately. */
/* max level keeps its gold — it is a one-second moment, not a surface */
.card.max{border-color:rgba(251,191,36,.65); box-shadow:0 0 0 2px rgba(251,191,36,.35), var(--shadow)}

/* The top-right corner, as one row: undo, the select tick, then the pill.
   They share a corner because they have to — the top-left belongs to the
   badge. One flex row rather than three absolutely placed boxes is also what
   stops a wide pill ("Lv.10") from sliding under the undo button, which is a
   bug that only shows up in the themes where the pill is widest.
   The three standalone rules below are neutralised here rather than rewritten,
   because `_tools/_style_compare.html` drops `.lvl` and `.undo` straight into
   a `.stage` with no row around them, and it still has to render. Neutralised
   BY NAME, and that is not a style preference: `.card .corner > *` is the same
   specificity as `.card .undo` and comes first in the file, so it lost — the
   three children stayed absolutely positioned against a zero-width `.corner`,
   which put the pill roughly where it used to be (so it looked right) and
   pushed `.undo` 8px past the card's right edge, where `overflow:hidden` ate
   it. A maxed card's undo button was simply not there. Measured 2026-10-10:
   `.corner` 0x0, `.undo` at x 176..204 on a card ending at 177. */
.card .corner{position:absolute; top:8px; right:8px; z-index:4; display:flex; align-items:center; gap:6px}
.card .corner > .undo,
.card .corner > .check,
.card .corner > .lvl{position:static; top:auto; right:auto}

/* the level pill — solid, high contrast, top-right. It is the one thing
   a kid at the back of the room can read, so it does not get a tint.
   It reads --accent/--accent-ink — ink-on-paper in every theme (the
   white-on-ink pairing left with twilight) — the highest contrast pair
   the theme has. */
.card .lvl{
  position:absolute; top:8px; right:8px; z-index:4;
  font-size:clamp(10px,.85vw,14px); font-weight:800; letter-spacing:.01em;
  padding:4px 10px; border-radius:999px;
  background:var(--accent); color:var(--accent-ink);
  box-shadow:0 3px 10px color-mix(in srgb,var(--accent) 38%,transparent);
}

.card .meta{
  flex:0 0 auto; position:relative; z-index:2;
  display:flex; flex-direction:column; justify-content:space-between;
  background:var(--sheet); border-radius:16px 16px 0 0; padding:13px 12px 16px;
  box-shadow:0 -5px 14px rgba(30,27,20,.07);
}
/* A flex row, not one text line: name and title are two items, so the chip
   can be the item that gives way. As a single nowrap line an overflowing
   name+title truncated as a unit — Chrome drew the ellipsis right after the
   name and dropped the chip, so on a 129px overview card "Liam" read as
   "Liam…" although the name itself had never been cut. Two items with the
   chip shrinking first (999 against the name's 1) means the name wins every
   argument — which is the rule this line lives under. */
.card .name{
  display:flex; align-items:baseline; gap:4px; margin-bottom:6px;
  font-weight:800; font-size:clamp(14px,1.35vw,22px); letter-spacing:-.01em;
}
.card .nname{
  flex:0 1 auto; min-width:0;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* The worn title (see "称号" in app.js): a quiet paper chip riding the name
   line. Neutral ink on tinted paper, the same language as every other
   caption: the colours on this screen still belong to the pets. */
.title{
  display:inline-block; padding:1px 7px; border-radius:999px;
  font-weight:700; color:var(--muted); font-size:clamp(9px, .68vw, 12px);
  background:color-mix(in srgb, var(--ink) 6%, transparent);
}
/* paintCard toggles the `hidden` attribute; a bare [hidden] loses to the
   display:inline-block above (author styles beat the UA sheet), so the
   hide has to be said in author CSS. */
.title[hidden]{display:none}
/* On the card the chip is the name line's second flex item — the one that
   ellipsises first and never takes a letter of the name with it. */
.card .title{
  flex:0 999 auto; min-width:0;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* The gender mark (see `parseNameLine` in app.js) — one ink glyph between
   the name and the title chip, in the three places a name wears its title.
   On the wall it is plain ink: the colours on that screen still belong to
   the pets (the blueprint's own rule), and this is a mark, not a feature —
   nothing in the app sorts, groups or counts by it.
   The roster and the student page are the one place it takes colour: the
   low-saturation blue/rose the teacher asked for in 2026-10-12, picked to
   sit on all four paper themes. Fixed width, never shrinks — one character
   is outside the truncation argument the name and the chip are having.
   2026-10-13: the first cut sat below the name's size and read too faint
   to the teacher. Size is now the name's own body, and the thickness comes
   from a hairline text-stroke: ♀ and ♂ fall outside Nunito's unicode-range
   and render from a system symbol face whose weight does not move the
   hairline strokes (A/B'd the same day — 700 → 900 changed nothing on
   screen), so font-weight alone was never going to read. */
.card .gmark{flex:0 0 auto; font-size:1em; font-weight:900; line-height:1; color:var(--muted); -webkit-text-stroke:.05em currentColor}
.srow .gmark{flex:0 0 auto; font-size:1em; font-weight:900; line-height:1; -webkit-text-stroke:.05em currentColor}
.shead-name .gmark{flex:0 0 auto; font-size:.9em; font-weight:900; line-height:1; -webkit-text-stroke:.05em currentColor}
.srow .gmark.m, .shead-name .gmark.m{color:#5b7cab}
.srow .gmark.f, .shead-name .gmark.f{color:#c06a8c}
.card .row{display:flex; align-items:center; gap:6px; margin-bottom:6px}
.card .dot{flex:0 0 auto; width:7px; height:7px; border-radius:50%; background:var(--muted)}
/* The label yields, the number never does — and neither ever wraps. Measured
   at 420x900 with 28 students: the card is 74px wide, which leaves 56px inside
   the sheet, and "12 / 20" alone wants 31 of the 6px that were left. Without
   nowrap the count folded onto a second line, the row grew 28px -> 42, and
   that 14px came straight off the stage: the pet's box fell 52 -> 38 in a card
   whose height had not changed at all. The dot already marks the row, so a
   truncated "Tre…" is the cheapest thing on screen to lose. */
.card .glabel{font-size:clamp(10px,.82vw,13px); font-weight:600; color:var(--muted);
  min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.card .treats{
  flex:0 0 auto; white-space:nowrap;
  margin-left:auto; font-size:clamp(10px,.82vw,13px); font-weight:800;
  font-variant-numeric:tabular-nums;   /* the numbers stop jittering as they tick */
}
/* space-between on .meta spreads the three rows over whatever height the
   sheet ends up with; the margins are what keeps them legible when the
   sheet is tight and there is no free space to spread. */
.card .bar{position:relative; height:7px; border-radius:999px; background:var(--bar)}
.card .bar i{
  display:block; position:relative; height:100%; width:0; border-radius:999px;
  background:var(--bar-ink); transition:width .6s cubic-bezier(.22,1,.36,1);
}
/* the knob marking where the pet is on the bar */
.card .bar i::after{
  content:''; position:absolute; right:0; top:50%; width:13px; height:13px; border-radius:50%;
  transform:translate(50%,-50%); background:var(--sheet);
  border:3px solid var(--accent); box-shadow:0 1px 4px rgba(25,22,18,.26);
  transition:opacity .3s;
}
.card .bar.zero i::after{opacity:0}    /* a bar at 0% gets no knob (nb: not `.empty` —
                                          that class is the no-students screen) */
.card.max .bar i{background:linear-gradient(90deg,#fde047,#f59e0b)}
.card.max .bar i::after{border-color:#f59e0b}

/* ── cramped cards ─────────────────────────────────────────────
   Measured at 420x900 with 24 students: the card is 95x133, and the sheet
   took 95 of those 133 pixels — 71% — leaving the stage 36 and shrinking
   the pet to 25px. Nothing was going to give that back on its own: `.meta`
   has an implicit min-height:auto, so it holds its content height, and
   `.stage` (min-height:0) absorbs the entire shortfall.

   The pet is the product; the caption is not. So on a card this short the
   sheet gives up its breathing room — padding and gaps first, then a couple
   of pixels of type — which hands the stage 26px and roughly doubles the
   pet. A container query rather than a media query because the card, not
   the window, is what is short: a tall window with 30 students cramps a
   card exactly as hard as a phone does.

   **182, not 170 — and do not move it down without re-running the scan.**
   Above the threshold the pet is width-limited, below it height-limited,
   and the sheet's 26px of extra height only costs the pet once height has
   become the binding constraint. Switch too early and the art INVERTS:
   forcing card heights through the scanner, 170px rendered the pet at 76
   and 175px at 62 — a bigger card with a smaller animal. 182 is where the
   full layout first fits a width-limited pet, so both branches compute the
   same art and the step disappears.

   Note what is deliberately absent: `.stage` keeps its 8px horizontal
   padding here. Trimming it to 4 would win 8px of pet width, but that 8px
   applies the instant the branch flips and cannot be smoothed by choosing a
   better threshold — it is a step in the pet's own width. It cost 2px of
   art at the target size, which is a cheap price for no inversion. */
@container card (max-height:182px){
  .card .meta{padding:6px 8px 8px}
  /* and the caption's strip goes with the caption — on a card this short
     the strip would be 24px of stage paying for a label that is not drawn
     (see .breed below). The row-height formula in layoutGrid never reaches
     this branch: it only ever produces cards for the scrolling wall, and
     the short cards all come from the overview, which sets its own rows. */
  .card .stage{padding-bottom:0}
  .card .name{font-size:12px; margin-bottom:3px}
  .card .row{margin-bottom:3px}
  .card .treats{font-size:10px}
  /* The word, not the count. A card this narrow holds the dot, "Treats" and
     "8 / 10" in about 58px of sheet, and the three of them want ~82 — so the
     label ellipsised to "Tre…" on every card, which reads as a rendering fault
     rather than a caption. The dot is what marks the row, so the word is the
     one part that can go without costing the teacher any information: the
     count stays whole and right-aligned. Same reasoning as .breed below. */
  .card .glabel{display:none}
  /* The breed caption sits in the slack under the art. A height-limited pet
     slot has no slack, so the label lands on the sheet and collides with the
     name. It is the third thing anyone needs here — picture and name come
     first — and at this size the string would truncate to "Golden Ret…". */
  .card .breed{display:none}
  /* Same reasoning as .breed above: the name is what this line is for. The
     title chip rides the name line, so keeping it costs the name its width —
     on a cramped card every name would ellipsise to pay for a caption. The
     title has two other homes (the student page and the roster) and none of
     them is a 12px name line. */
  .card .title{display:none}
  /* Same reasoning as everything else in this block: on a card this short the
     pet slot has nothing to spare, and a 20%-wide shield is the largest thing
     on screen. It stays legible and stops competing — the badge is a reward,
     and a reward that eats the animal is a punishment. */
  .card .badge{--bw:16px}
}
/* The other half of "窄卡隐藏": width. A 30-student overview draws cards
   129px wide and there is no room for a caption there — the chip would be
   an ellipsis pretending to be a reward. 160 sits between that 129 and the
   191 of a two-column phone, which wears the chip fine. (The height branch
   above catches short cards; this catches narrow ones; in between, the
   flex rules on .name keep the name whole and let the chip ellipsise.) */
@container card (max-width:160px){
  .card .title{display:none}
}

/* In the app this sits in `.corner` at the top-right, next to the pill —
   `.corner > *` strips this positioning back off. The coordinates here are
   for the standalone case (`_tools/_style_compare.html` draws a bare
   `.undo` into a `.stage`), and so is `.check`. */
.card .undo{
  position:absolute; top:8px; left:8px; z-index:4; width:28px; height:28px; border-radius:50%;
  display:grid; place-items:center; cursor:pointer; font-size:15px; line-height:1;
  border:1px solid var(--card-edge); background:color-mix(in srgb,var(--sheet) 82%,transparent);
  color:var(--muted); opacity:.5; transition:.2s;
}
.card:hover .undo{opacity:1}
.card .undo:hover{color:var(--accent); border-color:var(--accent)}
.card .check{
  position:absolute; top:8px; left:8px; z-index:4; width:28px; height:28px; border-radius:50%;
  display:none; align-items:center; justify-content:center; font-size:15px; font-weight:900;
  background:var(--accent); color:var(--accent-ink);
  box-shadow:0 2px 8px color-mix(in srgb,var(--accent) 45%,transparent);
}
body.selecting .card .check{display:flex; opacity:.22}
body.selecting .card.sel .check{opacity:1; transform:scale(1.12)}
body.selecting .card .undo{display:none}
body.selecting .card.sel{outline:3px solid var(--accent); outline-offset:-3px}

/* animations */
.card.gain{animation:gain .6s ease-out}
@keyframes gain{
  0%{transform:scale(1)} 22%{transform:scale(1.06)} 46%{transform:scale(.99)} 100%{transform:scale(1)}
}
.card.levelup{animation:lvlup 1.1s cubic-bezier(.34,1.56,.64,1)}
@keyframes lvlup{
  0%{transform:scale(1)} 30%{transform:scale(1.14); box-shadow:0 0 0 6px rgba(251,191,36,.35),0 20px 44px rgba(200,140,20,.35)}
  60%{transform:scale(.97)} 100%{transform:scale(1)}
}
.card.shake{animation:shake .5s ease}
@keyframes shake{
  0%,100%{transform:translateX(0)} 20%{transform:translateX(-5px)} 40%{transform:translateX(5px)}
  60%{transform:translateX(-3px)} 80%{transform:translateX(3px)}
}
/* `crownFloat` lived here. It only ever had two consumers — the card crown and
   the ceremony's 👑 — and both are gone; the badge's lift is `badgeStamp` plus
   `badgeSheen`, which are up with the badge. */

.float{
  position:absolute; left:50%; top:34%; z-index:5; pointer-events:none;
  font-weight:900; font-size:clamp(22px,2.4vw,34px); color:var(--good);
  text-shadow:0 3px 12px rgba(0,0,0,.18);
  animation:floatUp 1.15s cubic-bezier(.22,1,.36,1) forwards;
}
.float.neg{color:var(--bad)}
@keyframes floatUp{
  0%{opacity:0; transform:translate(-50%,12px) scale(.6)}
  22%{opacity:1; transform:translate(-50%,-6px) scale(1.18)}
  100%{opacity:0; transform:translate(-50%,-84px) scale(1)}
}
.card .burst{
  position:absolute; inset:0; z-index:3; pointer-events:none; border-radius:20px;
  background:radial-gradient(circle at 50% 45%, rgba(253,224,71,.85), transparent 62%);
  animation:burst .8s ease-out forwards;
}
@keyframes burst{0%{opacity:0; transform:scale(.4)} 35%{opacity:1} 100%{opacity:0; transform:scale(1.5)}}

/* ── empty state ───────────────────────────────────────────────
   Two halves on a roomy screen: the words, and beside them the class
   photo, whose eight eyes follow the cursor (app.js, `coverMove`). Phones
   keep the screen this always was — one centred column — because a stage
   squeezed to 420px is a queue of overlapping paws. */
.empty{
  position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
  text-align:center; padding:24px; z-index:2;
}
.empty-copy{display:flex; flex-direction:column; align-items:center; gap:10px}
.empty-art{font-size:74px; filter:drop-shadow(0 12px 24px rgba(30,27,20,.22))}
.empty h1{margin:0; font-size:clamp(24px,3vw,38px); letter-spacing:-.02em}
.empty p{margin:0; max-width:460px; color:var(--muted); font-size:16px; line-height:1.5}
.empty-btns{display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; justify-content:center}
/* The quiet compare-page line (2026-10-13). One muted sentence under the
   buttons — the third path off this screen, ranked below both buttons on
   purpose. `margin-top:0` because `.empty-copy` already spaces its children
   with a 10px gap. */
.empty-note{margin:0; font-size:13px; color:var(--muted)}
.empty-note a{color:inherit; text-decoration:underline; text-underline-offset:3px}
.empty-note a:hover{color:var(--ink)}

.empty-scene{display:none}
@media (min-width:900px){
  .empty{flex-direction:row; gap:clamp(16px,3.5vw,60px)}
  .empty-scene{display:block; position:relative; top:-3vh; flex:none; width:min(44vw,620px); aspect-ratio:1}
  /* The stage is the photo, and the photo is the whole cover — one static
     picture (background flood-removed in _tools/cover_cut.mjs; contact
     shadow baked in). A three-layer eye-tracking rig used to live here and
     was dropped 2026-10-11: it read as fake. No animation, no cursor
     wiring, nothing to smooth — just the class. */
  .cover-photo{display:block; width:100%; user-select:none; -webkit-user-drag:none}
}

/* ── drawer ──────────────────────────────────────────────── */
.drawer{
  position:fixed; inset:0; z-index:40; display:flex; align-items:flex-end;
  background:rgba(20,12,4,.32); backdrop-filter:blur(3px);
  animation:fade .18s ease;
}
.drawer[hidden]{display:none}
@keyframes fade{from{opacity:0}to{opacity:1}}
.drawer-inner{
  width:100%; max-height:78vh; overflow-y:auto;
  background:linear-gradient(180deg,var(--card),var(--card));
  border-top:1px solid var(--card-edge); border-radius:26px 26px 0 0;
  box-shadow:0 -20px 60px rgba(80,50,10,.28); padding:16px 18px 22px;
  animation:slideUp .26s cubic-bezier(.22,1,.36,1);
}
@keyframes slideUp{from{transform:translateY(30px); opacity:.6}to{transform:translateY(0); opacity:1}}
.drawer-head{display:flex; align-items:center; justify-content:space-between; gap:14px; margin-bottom:14px}
.drawer-name{font-size:clamp(20px,2.2vw,28px); font-weight:900; letter-spacing:-.02em}
.drawer-meta{font-size:14px; font-weight:600}
.items{display:grid; grid-template-columns:repeat(auto-fill,minmax(140px,1fr)); gap:10px}
.item{
  display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px;
  padding:12px 8px; border-radius:16px; cursor:pointer; font:inherit; color:inherit;
  background:var(--card); border:1px solid var(--card-edge);
  transition:transform .12s ease, border-color .15s, box-shadow .15s, background .15s;
}
.item:hover{border-color:var(--accent); transform:translateY(-2px); box-shadow:var(--shadow)}
.item:active{transform:scale(.96)}
.item .ic{font-size:24px; line-height:1}
.item .lb{font-size:12.5px; font-weight:700; text-align:center; line-height:1.25}
.item .dl{font-size:15px; font-weight:900; color:var(--good)}
.item.neg .dl{color:var(--bad)}
.item.flash{animation:flash .45s ease}
@keyframes flash{0%{background:color-mix(in srgb,var(--accent) 30%,transparent)}100%{background:var(--card)}}

/* ── ceremony ────────────────────────────────────────────── */
.ceremony{
  position:fixed; inset:0; z-index:70; display:flex; align-items:center; justify-content:center;
  background:rgba(20,12,4,.45); backdrop-filter:blur(5px); animation:fade .25s ease; padding:20px;
}
.ceremony[hidden]{display:none}
.ceremony-card{
  position:relative; z-index:2; text-align:center; padding:30px 40px 26px; border-radius:28px;
  background:var(--card); border:1px solid var(--card-edge); box-shadow:var(--shadow-lg);
  animation:pop .5s cubic-bezier(.34,1.56,.64,1); max-width:min(560px,92vw);
}
@keyframes pop{from{transform:scale(.7) translateY(20px); opacity:0}to{transform:scale(1) translateY(0); opacity:1}}
.ceremony-card h2{margin:6px 0 4px; font-size:clamp(26px,3.4vw,40px); letter-spacing:-.02em;
  background:linear-gradient(90deg,#f59e0b,#fbbf24,#f97316); -webkit-background-clip:text; background-clip:text; color:transparent}
.ceremony-card p{margin:0 0 16px; font-size:16px; font-weight:600}
.cer-btns{display:flex; gap:10px; justify-content:center; flex-wrap:wrap}
/* The pet and the shield, side by side. The pet used to live INSIDE the
   shield's face; the face is the count now, so the pet stands on its own,
   full frame, and the badge lands beside it the way it lands on the card.
   `font-size` is for the fallback — the emoji that stands in when a webp
   misses (app.js swaps it into the img's place). */
.cer-pet{
  display:flex; align-items:center; justify-content:center;
  gap:clamp(10px,2.5vw,18px);
  font-size:64px; margin-bottom:18px; line-height:1;
  animation:cerBounce 1.5s ease-in-out infinite;
}
.cer-pet-img{height:clamp(110px,24vw,150px); width:auto}
@keyframes cerBounce{
  0%,100%{transform:translateY(0) scale(1,1)}
  28%{transform:translateY(-9%) scale(.96,1.05)}
  50%{transform:translateY(0)   scale(1.06,.95)}
  72%{transform:translateY(-3%) scale(1,1)}
}
/* The ceremony's badge, where the crown used to be. Its width is the one
   place in the file that is allowed to be viewport-relative: the modal is
   centred on the screen and capped at `min(560px,92vw)`, so the viewport is
   genuinely the ruler here, and the alternative (`cqw` with no container
   ancestor) silently falls back to the viewport anyway — the difference is
   that this way it says so. */
.cer-pet .badge{--bw:clamp(84px, 22vw, 120px)}
/* The ceremony is the one place the badge gets the whole act, and faster: the
   modal is on screen for as long as it takes to read one sentence, so the
   card's 4.4s sheen would fire once at most. The halo is on a loop from the
   first frame rather than pulsing up from nothing — this is the reveal, the
   shield should already be lit. */
.cer-pet .badge::before{animation:badgeGlow 2.2s ease-in-out infinite}
.cer-pet .badge .shield::after{animation:badgeSheen 2.6s ease-in-out .35s infinite}
.cer-pet .badge .tw{animation:twinkle 1.9s ease-in-out infinite}
.cer-pet .badge .tw-b{animation-delay:.95s}
@media (prefers-reduced-motion:reduce){.cer-pet{animation:none}}

canvas.confetti{position:fixed; inset:0; z-index:65; pointer-events:none; display:none}
canvas.confetti.on{display:block}

/* ── selection bar ───────────────────────────────────────── */
.selbar{
  position:fixed; left:50%; bottom:26px; transform:translateX(-50%); z-index:45;
  display:flex; align-items:center; gap:14px; padding:11px 13px 11px 22px;
  background:var(--card); border:1px solid var(--card-edge); border-radius:999px;
  box-shadow:var(--shadow-lg); backdrop-filter:blur(10px);
  animation:toastIn .22s ease;
}
.selbar-text{font-weight:800; font-size:15px; white-space:nowrap}

/* ── toast ───────────────────────────────────────────────── */
.toast{
  position:fixed; left:50%; bottom:26px; transform:translateX(-50%); z-index:80;
  background:rgba(28,20,10,.92); color:#fff; font-weight:700; font-size:14px;
  padding:11px 18px; border-radius:999px; box-shadow:0 12px 30px rgba(0,0,0,.3);
  animation:toastIn .25s ease;
}
.toast[hidden]{display:none}
@keyframes toastIn{from{opacity:0; transform:translate(-50%,12px)}to{opacity:1; transform:translate(-50%,0)}}

/* ── leaderboard ─────────────────────────────────────────────
   Podium on top, whole class below it. The tail of that list is
   deliberately plain: a child shown in last place, in red, on a
   screen the whole room can see, is the fastest way to lose the
   teachers this is built for. Ranks stay (teachers need them),
   the bottom just doesn't get pointed at. */
/* Fixed header over a scrolling body, rather than a sticky header: sticky
   needs its own background to hide the rows sliding under it, and that
   background never quite matches the blurred page behind it — a visible
   seam across the screen. Splitting the two means no overlap, no seam. */
.board{
  position:fixed; inset:0; z-index:50;
  display:flex; flex-direction:column;
  background:color-mix(in srgb, var(--bg1) 94%, transparent);
  backdrop-filter:blur(10px);
}
.board[hidden]{display:none}
.board-inner{
  flex:1 1 auto; min-height:0; width:100%; max-width:900px; margin:0 auto;
  display:flex; flex-direction:column; padding:0 18px;
}
.board-scroll{
  flex:1 1 auto; min-height:0; overflow:auto; padding-bottom:44px;
  scrollbar-width:thin; scrollbar-color:var(--card-edge) transparent;
}
.board-head{
  flex:0 0 auto;
  display:flex; align-items:center; justify-content:space-between; gap:14px;
  padding:22px 0 14px;
}
.board-head h2{margin:0; font-size:clamp(20px,2.2vw,30px); letter-spacing:-.02em}
.board-sub{margin:4px 0 0; font-size:13px; font-weight:600; color:var(--muted)}
/* the board's Treats/Badges pick — a .seg wearing a smaller type size than
   the Setup one, because it lives under a headline rather than in a panel */
.board-by{margin-top:10px}
.board-by .btn{padding:6px 13px; font-size:13px; border-radius:10px}
.board-none{margin:6px 2px; font-weight:600; color:var(--muted)}

/* -- podium -- */
.podium{display:flex; justify-content:center; align-items:flex-end; gap:12px; margin-bottom:22px}
.podium:empty{display:none; margin:0}
/* Silver left, gold in the middle, bronze right — the shape of a medal stand,
   which is the one arrangement nobody has to be told how to read, and it puts
   the winner where the eye already goes.
   `order`, not DOM order, and that is deliberate on both ends. The markup stays
   ranked 1st/2nd/3rd, because that is the order the ranks mean and the order a
   screen reader should hear; the arrangement is decoration on top of it. It
   also means the board's own test — `pods.map(p => p.place).join() === '1,2,3'`
   — is still asserting the ranking rather than the layout. Reordering the DOM
   instead would break that assertion and the reading order with it.
   Ties need no special case: place repeats, so a three-way tie for first gives
   all three `order:2` and they hold their own DOM order among themselves. */
.pod[data-place="1"]{order:2}
.pod[data-place="2"]{order:1}
.pod[data-place="3"]{order:3}
.pod{
  flex:1 1 0; min-width:0; display:flex; flex-direction:column; align-items:center; gap:7px;
  padding:16px 10px; border-radius:20px; text-align:center;
  background:var(--card); border:1px solid var(--card-edge); box-shadow:var(--shadow);
}
/* keyed on place, not on position: three children tied for first all get
   gold, which is the honest reading — none of them is above the others. */
.pod[data-place="1"]{
  padding-top:28px; padding-bottom:28px; border-color:#f0b429;
  box-shadow:0 12px 34px rgba(240,180,41,.3);
}
.pod[data-place="2"]{border-color:#b9c2cf}
.pod[data-place="3"]{border-color:#dda06a}
/* the 🥇/🥈/🥉 under .pod. Named .place — .medal is the badge-wall
   medallion further down, and two systems on one class name resized these. */
.place{font-size:clamp(24px,3vw,38px); line-height:1}
.podname{
  max-width:100%; font-weight:800; font-size:clamp(13px,1.3vw,18px); letter-spacing:-.01em;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.podtreats{font-size:clamp(11px,.95vw,14px); font-weight:700; color:var(--muted); font-variant-numeric:tabular-nums}

/* -- the class list -- */
.board-list{display:flex; flex-direction:column; gap:6px}
.brow{
  position:relative; display:flex; align-items:center; gap:10px;
  padding:clamp(7px,.72vw,12px) 14px; border-radius:14px; overflow:hidden;
  background:var(--card); border:1px solid var(--card-edge);
}
/* A fill behind the row, proportional to the leader.
   Flat tint across the bar, then a short fade over the last 36px. A ramp
   across the whole bar looks softer but ends on a 10-unit step where the
   gradient runs out — a hard vertical cut mid-row, which reads as a
   rendering glitch rather than as the end of a bar. Fading to nothing means
   there is no edge to notice, and 36px is short enough that you can still
   see where the bar ends.
   No transition: renderBoard() rebuilds these from scratch, so the width is
   already final by the time the element lands in the document. */
.bfill{
  position:absolute; left:0; top:0; bottom:0; z-index:0; width:0;
  background:linear-gradient(90deg,
    color-mix(in srgb, var(--accent) 17%, transparent) calc(100% - 36px),
    transparent 100%);
}
.brow > :not(.bfill){position:relative; z-index:1}
.brank{
  flex:0 0 auto; min-width:24px; text-align:right;
  font-weight:800; font-size:clamp(12px,1vw,17px); color:var(--muted); font-variant-numeric:tabular-nums;
}
.bname{
  flex:1 1 auto; min-width:0; font-weight:700; font-size:clamp(14px,1.15vw,19px);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.blv{flex:0 0 auto; font-size:clamp(11px,.85vw,14px); font-weight:800; color:var(--muted)}
.btreats{
  flex:0 0 auto; min-width:48px; text-align:right;
  font-weight:800; font-size:clamp(14px,1.15vw,19px); font-variant-numeric:tabular-nums;
}

/* -- pet thumbnails (wall-sized cards use .card .ph/.pet img instead) -- */
.bthumb{
  --th:clamp(34px,3.1vw,50px); position:relative; flex:0 0 auto;
  width:var(--th); height:var(--th);
  border-radius:50%; display:grid; place-items:center; overflow:hidden;
}
.bthumb .bph{font-size:calc(var(--th) * .56); line-height:1}
.bthumb img{position:absolute; inset:0; width:100%; height:100%; object-fit:contain}
.pod .bthumb{--th:clamp(44px,6vw,76px)}

/* ── student info ─────────────────────────────────────────────
   Two screens in one overlay: the class list, and one student's page. It
   borrows `.board` whole — same kind of thing (a full-screen overlay with a
   fixed head and exactly one scroller), and the two are never open at once.
   What is new is the row, the student header, and the two tally tables. */
.roster-back{margin:2px 0 14px}
/* The roster's find-a-name box: a static node above the (rebuilt-every-sync)
   list, wearing the Setup sheet's input shape. */
.roster-search{margin:2px 0 12px}
.roster-search input{
  width:100%; font:inherit; font-size:15px; padding:10px 13px; border-radius:12px;
  border:1px solid var(--card-edge); background:var(--card); color:var(--ink);
}
.roster-search input:focus{outline:2px solid var(--accent); outline-offset:-1px}
.srow{
  display:flex; align-items:center; gap:10px; width:100%; text-align:left;
  font:inherit; color:inherit; cursor:pointer; margin-bottom:6px;
  padding:clamp(7px,.72vw,12px) 14px; border-radius:14px;
  background:var(--card); border:1px solid var(--card-edge);
  transition:border-color .15s ease, transform .12s ease;
}
.srow:hover{border-color:var(--accent); transform:translateX(2px)}
/* 0 1 auto, not 1 1 auto: the name used to absorb every spare pixel, which
   pushed the title to the far right edge. Now the title hugs the name and
   the leftover space pools before the level, which `margin-left:auto` below
   hands back to the right edge — where the quiet badge count ends the row. */
.srow .sname{
  flex:0 1 auto; min-width:0; font-weight:800; font-size:clamp(14px,1.15vw,19px);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* The worn title, riding the name's line the way it does on the card. It
   shrinks first and all the way (999 against the name's 1): a squeezed row
   loses the caption, never a letter of the name. The -5px claws back half
   the row's 10px gap so the chip reads as attached to the name rather than
   adrift between it and the badge. */
.srow .title{
  flex:0 999 auto; min-width:0; margin-left:-5px;
  overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
  font-size:clamp(10px,.8vw,13px);
}
.srow .slv{flex:0 0 auto; margin-left:auto; font-size:clamp(11px,.85vw,14px); font-weight:800; color:var(--muted)}
.srow .streats{
  flex:0 0 auto; min-width:70px; text-align:right; color:var(--muted);
  font-weight:800; font-size:clamp(13px,1.05vw,17px); font-variant-numeric:tabular-nums;
}

/* one student: the pet and who it belongs to */
.shead{display:flex; align-items:center; gap:14px; margin:0 0 18px}
.shead .bthumb{--th:clamp(52px,6vw,84px)}
.shead-name{font-size:clamp(20px,2.1vw,28px); font-weight:800; letter-spacing:-.02em}
.shead-line{margin-top:3px; font-size:13px; font-weight:600; color:var(--muted)}
/* The worn title in the header: the same chip, one size up, wearing a caret
   because this one is a door — the only control in the app that changes how
   a student is addressed. Neutral before, during and after the tap: on this
   screen the colour is already spoken for by the pet. A real <button>, so
   the caret is the only decoration; the chip's own dressing stays in .title. */
.shead-name .title{
  margin-left:10px; vertical-align:middle; padding:3px 12px;
  font-size:clamp(11px,.95vw,15px); font-family:inherit;
  border:1px solid transparent; cursor:pointer;
}
.shead-name .title::after{content:'▾'; margin-left:7px; font-size:.85em; opacity:.6}
.shead-name .title:hover{border-color:var(--accent)}

/* The picker itself, opened under the header line. Every title earned so
   far, newest rung on top, each row wearing the ladder's own shield at that
   rung (badgeEl of the threshold count) — so the menu teaches the ladder
   while it is used. The worn row is tinted and ticked; the next rung, when
   there is one, is a sentence rather than a locked row: no dead buttons on
   a page about children. */
.titlepick{
  margin:12px 0 0; padding:10px 12px; width:max-content; max-width:100%;
  border:1px solid var(--card-edge); border-radius:16px; background:var(--card);
}
.tp-h{
  margin:0 2px 6px; font-size:11px; font-weight:800; letter-spacing:.05em;
  text-transform:uppercase; color:var(--muted);
}
.tp-row{
  display:flex; align-items:center; gap:10px; width:100%; text-align:left;
  font-family:inherit; font-weight:700; font-size:clamp(13px,1.05vw,16px);
  color:inherit; cursor:pointer; padding:6px 10px;
  border:1px solid transparent; border-radius:12px; background:none;
}
.tp-row .tp-label{flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
/* A weekly honour's row wears its category icon where a rung row wears a
   shield — same 20px box, one is a picture of a count, the other is the
   choice itself. Deliberately neutral: no colour, no ring, nothing that
   reads as a rank. */
.tp-ic{
  flex:0 0 auto; display:flex; align-items:center; justify-content:center;
  width:20px; height:20px; font-size:12.5px; line-height:1; border-radius:6px;
  background:color-mix(in srgb, var(--ink) 7%, transparent);
}
.tp-row:hover{border-color:var(--accent)}
.tp-row.on{background:color-mix(in srgb, var(--ink) 6%, transparent)}
.tp-check{margin-left:2px; flex:0 0 auto; font-weight:900; color:var(--good)}
.tp-next{margin:8px 2px 2px; font-size:12px; font-weight:600; color:var(--muted)}

/* The two numbers the page exists for, stated before the detail. Both are
   DOORS now — the page became a hub and these two tiles are the only things
   on it that lead anywhere. The caret is the same one the title chip wears,
   for the same reason: the one hint that this number is a place. */
.sfacts{display:flex; gap:10px; flex-wrap:wrap; margin:0 0 8px}
.sfact{
  flex:1 1 160px; display:flex; align-items:center; gap:9px;
  padding:11px 16px; border-radius:14px;
  background:var(--card); border:1px solid var(--card-edge);
}
button.sfact{font:inherit; color:inherit; text-align:left; cursor:pointer}
button.sfact:hover{border-color:var(--accent)}
button.sfact::after{content:'›'; margin-left:9px; font-size:1.15em; font-weight:800; color:var(--muted)}
.sfact .sfk{font-weight:700; font-size:13px; color:var(--muted)}
.sfact .sfv{
  margin-left:auto; font-weight:800; font-size:clamp(17px,1.5vw,22px);
  font-variant-numeric:tabular-nums;
}
/* "Times scored" — a fact about the log below, not a place. It sits under
   the doors in a smaller, quieter register so the row above is unmistakably
   "two things you can open, then everything else". */
.sfoot{
  display:flex; align-items:baseline; gap:8px; margin:0 2px 20px;
  font-size:12px; font-weight:600; color:var(--muted);
}
.sfoot .sfv{
  font-weight:800; font-size:13px; color:var(--ink);
  font-variant-numeric:tabular-nums;
}

/* ── the shelf, on the badges sub-page ─────────────────────────
   The gallery is the order the badges arrived in — first earned, first on
   the shelf — and it holds nothing else: no reserved places, nothing that
   counts what a child has not met yet. Each badge is a medal: gold rim,
   navy field, the pet's portrait at its centre, the star part of the
   chrome (rank lives on the ladder below, not on the medal). Drawn
   entirely in CSS — no image assets. */
.bgrid{
  --mw:clamp(64px,8.5vw,116px);
  display:grid; gap:clamp(8px,1.1vw,16px);
  grid-template-columns:repeat(auto-fill, var(--mw)); justify-content:start;
}
.medal{
  position:relative; width:var(--mw); aspect-ratio:1/1; border-radius:50%;
  background:radial-gradient(circle at 36% 28%,
    #fff6cf, #f6cf5a 30%, #e0aa2e 55%, #9a6c14 80%, #c3911f 100%);
  box-shadow:0 3px 10px rgba(0,0,0,.16),
             inset 0 -3px 8px rgba(110,72,0,.4), inset 0 2px 4px rgba(255,255,255,.55);
}
.medal::before{           /* the navy field, ringed in gold inside the rim */
  content:''; position:absolute; inset:10%; border-radius:50%;
  background:radial-gradient(circle at 40% 28%, #46588f, #2c3a68 52%, #1a2342 100%);
  box-shadow:inset 0 0 0 2px rgba(246,207,90,.8),
             inset 0 -8px 14px rgba(0,0,0,.38), inset 0 4px 10px rgba(255,255,255,.1);
}
.medal .mstar{
  position:absolute; top:3.2%; left:50%; width:17%; aspect-ratio:1/1;
  transform:translateX(-50%); z-index:3;   /* pinned in front of the pet: the
     heads sit at different heights, and a half-buried star looked accidental */
  background:linear-gradient(#fff6cf, #dfa92c);
  clip-path:polygon(50% 0, 61% 35%, 98% 36%, 68% 57%, 79% 96%, 50% 73%, 21% 96%, 32% 57%, 2% 36%, 39% 35%);
  filter:drop-shadow(0 1px 1px rgba(90,60,0,.5));
}
.medal .mph{              /* the portrait's stand-in until the photo loads */
  position:absolute; inset:0; margin:auto; width:56%; aspect-ratio:1/1;
  display:grid; place-items:center; z-index:1;
  font-size:calc(var(--mw) * .34); color:#f6cf5a;
}
/* the pet files are transparent cutouts, so nothing frames the picture:
   the pet itself sits centred on the field. Sized so the ears just meet
   the star and the tail may cross the ring — at this scale that reads
   as the pet standing in the medal, which is the point. */
.medal img{
  position:absolute; inset:0; margin:auto; width:74%; aspect-ratio:1/1;
  object-fit:contain; z-index:2;
  filter:drop-shadow(0 3px 6px rgba(0,0,0,.42));
}
/* the ladder reuses .trow whole: shield, title, tick, count */
.trow.ladder .tic{display:flex; align-items:center}
.trow.ladder.dim{opacity:.45}
.trow.ladder .tn{
  color:var(--muted); font-weight:700; min-width:0;
  font-size:clamp(12px,.95vw,15px);
}

/* The ceremony's door, reopened for a pet that is maxed and not yet awarded
   (its modal can be closed without deciding). Primary weight on purpose —
   for a student standing on the top rung, this tap is The Event, and it
   leads to the modal rather than awarding quietly from a list row. */
.award-btn, .reroll-btn{margin:0 0 20px}

/* the two tally tables. Counts, not a balance — see tallyAll(). */
.tsec{margin-bottom:20px}
.tsec h3{
  margin:0 0 8px; font-size:12px; font-weight:800; letter-spacing:.05em;
  text-transform:uppercase; color:var(--muted);
}
.trow{
  display:flex; align-items:center; gap:10px; margin-bottom:6px;
  padding:9px 14px; border-radius:12px;
  background:var(--card); border:1px solid var(--card-edge);
}
.trow .tic{flex:0 0 auto; font-size:17px; line-height:1}
.trow .tlb{
  flex:1 1 auto; min-width:0; font-weight:700; font-size:clamp(13px,1.05vw,17px);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.trow .tdl{
  flex:0 0 auto; color:var(--good); font-weight:800;
  font-size:clamp(12px,1vw,16px); font-variant-numeric:tabular-nums;
}
.trow.neg .tdl{color:var(--bad)}
.trow .tn{
  flex:0 0 auto; min-width:46px; text-align:right; font-weight:800;
  font-size:clamp(13px,1.1vw,18px); font-variant-numeric:tabular-nums;
}
.tnone{margin:2px; font-weight:600; color:var(--muted)}
.tnote{margin:10px 2px 2px; font-weight:600; font-size:13px; color:var(--muted)}

/* ── reward shop ──────────────────────────────────────────────
   Rows on the student page. The star is a glyph in content, not a colour —
   the same licence the 🏆 in the board's own heading already has; no new
   hue enters the palette. `position:relative` is load-bearing: the spend
   float is positioned against its row (the same reason `.card` carries it). */
.shoprow{
  position:relative; display:flex; align-items:center; gap:8px; margin-bottom:6px;
  padding:9px 14px; border-radius:12px;
  background:var(--card); border:1px solid var(--card-edge);
}
.shoprow .sic{flex:0 0 auto; font-size:17px; line-height:1}
.shoprow .slb{
  flex:1 1 auto; min-width:0; font-weight:700; font-size:clamp(13px,1.05vw,17px);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.shoprow .snote{flex:0 0 auto; font-size:12px; font-weight:700; color:var(--muted); white-space:nowrap}
.shoprow .scost{
  flex:0 0 auto; min-width:38px; text-align:right; font-weight:800;
  font-size:clamp(12px,1vw,16px); font-variant-numeric:tabular-nums;
}
.shoprow .sredeem{
  flex:0 0 auto; font:inherit; font-size:13px; font-weight:800; cursor:pointer;
  padding:7px 12px; border-radius:10px; border:1px solid transparent;
  background:var(--accent); color:var(--accent-ink);
}
/* Sold out and can-not-afford wear the same neutral: nothing here is red and
   nothing points at the child. */
.shoprow .sredeem:disabled{
  background:transparent; color:var(--muted); border-color:var(--card-edge); cursor:default;
}
/* A spend is not a loss — it is a green number on the take-away row otherwise */
.trow.spend .tdl{color:var(--muted)}
.trow .tundo{
  flex:0 0 auto; width:30px; height:30px; padding:0; cursor:pointer;
  border-radius:10px; border:1px solid var(--card-edge); background:transparent; color:var(--muted);
  font:inherit; font-size:14px; line-height:1;
}
.trow .tundo:hover{color:var(--ink)}
/* The spend float rides on a ~46px row, not a card — the card's clamp would
   shout out of a row this size. */
.float.spend{color:var(--muted); font-size:clamp(15px,1.5vw,20px)}

/* the shop editor in Setup: emoji · name · price · stock · ✕ */
.itemrow.shop{grid-template-columns:52px 1fr 84px 84px 40px}
.itemrow input.cost, .itemrow input.stock{text-align:center; font-weight:800}

/* ── weekly titles ────────────────────────────────────────────
   The editor row (emoji · title · what to count · how to count · ✕) and the
   settle area under it. The review rows reuse the row language of the two
   tally tables — a card, a name that ellipsizes first, a tabular number,
   and at most one button on the right. */
/* The two selects carry the longest strings in the app ("Perfect attendance
   (week)", "Most improved"), and a native select clips rather than wraps, so
   they get widths picked off the text rather than off the other rows. 224/132
   still fits a 641px window, the narrowest one that does not fall through to
   the phone layout below. */
.itemrow.weekly{grid-template-columns:52px 1fr 224px 132px 40px}
.itemrow select{
  width:100%; min-width:0; font:inherit; font-size:13px; font-weight:700;
  padding:9px 8px; border-radius:10px; border:1px solid var(--card-edge);
  background:var(--card); color:var(--ink);
}
.wcount{
  margin:14px 2px 10px; font-size:11px; font-weight:800; letter-spacing:.05em;
  text-transform:uppercase; color:var(--muted);
}
.wsec{margin:0 0 12px}
.wsec .wh{margin:0 0 6px; font-weight:800; font-size:13.5px}
.wsec .hint{margin:2px 2px 0}
.wrow{
  display:flex; align-items:center; gap:10px; margin-bottom:6px;
  padding:8px 12px; border-radius:12px;
  background:var(--card); border:1px solid var(--card-edge);
}
.wrow .wic{flex:0 0 auto; font-size:16px; line-height:1}
.wrow .wnm{
  flex:1 1 auto; min-width:0; font-weight:700; font-size:13.5px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.wrow .wval{
  flex:0 0 auto; font-weight:800; font-size:13.5px;
  font-variant-numeric:tabular-nums; color:var(--good);
}
.wrow .wck{flex:0 0 auto; font-weight:900; color:var(--good)}
/* Decided: the row steps back so the remaining Award buttons own the eye. */
.wrow.done{background:transparent; border-color:transparent}
.wrow .waw{
  flex:0 0 auto; font:inherit; font-size:12.5px; font-weight:800; cursor:pointer;
  padding:7px 12px; border-radius:10px; border:1px solid transparent;
  background:var(--accent); color:var(--accent-ink);
}
.wrow .wundo{
  flex:0 0 auto; width:30px; height:30px; padding:0; cursor:pointer;
  border-radius:10px; border:1px solid var(--card-edge); background:transparent;
  color:var(--muted); font:inherit; font-size:14px; line-height:1;
}
.wrow .wundo:hover{color:var(--ink)}
#weeklyReview .btn{margin-top:2px}
#weeklyReview .btn.ghost:last-child{margin-bottom:2px}

/* ── setup ───────────────────────────────────────────────── */
#setup-view{overflow-y:auto; display:block}
#setup-view .topbar{position:sticky; top:0; z-index:5; opacity:1; backdrop-filter:blur(8px)}
.setup-wrap{
  max-width:860px; margin:0 auto; padding:8px 20px 60px;
  display:grid; gap:16px;
}
.panel{
  background:var(--panel); border:1px solid var(--card-edge); border-radius:20px;
  padding:18px 20px; box-shadow:var(--shadow); backdrop-filter:blur(6px);
}
.panel h2{margin:0 0 14px; font-size:17px; letter-spacing:-.01em}
.panel h2 em{font-style:normal; font-weight:600; color:var(--muted); font-size:13px}
.field{display:block; margin-bottom:14px}
.field > span{display:block; font-size:13px; font-weight:700; margin-bottom:6px}
.field > span em{font-style:normal; font-weight:600; color:var(--muted)}
.field input[type=text], .field textarea{
  width:100%; font:inherit; font-size:15px; padding:11px 13px; border-radius:12px;
  border:1px solid var(--card-edge); background:var(--card); color:var(--ink); resize:vertical;
}
.field input:focus, .field textarea:focus{outline:2px solid var(--accent); outline-offset:-1px}
.row-btns{display:flex; gap:9px; flex-wrap:wrap}
.hint{margin:10px 0 0; font-size:12.5px; color:var(--muted); line-height:1.5}
.switch{display:flex; align-items:center; gap:10px; font-size:14px; font-weight:600; margin-bottom:10px; cursor:pointer}
.switch input{width:18px; height:18px; accent-color:var(--accent)}
.switch em{font-style:normal; font-weight:500; color:var(--muted); font-size:13px}
.themepicker{display:flex; gap:10px; flex-wrap:wrap}
.swatch{
  width:52px; height:40px; border-radius:12px; cursor:pointer; border:2px solid transparent;
  box-shadow:var(--shadow); transition:transform .15s;
}
.swatch:hover{transform:translateY(-2px)}
.swatch.on{border-color:var(--accent); transform:translateY(-2px)}
/* Each chip is a miniature of the card it picks: the stage band on top, the
   sheet under it, split at the same place the real card splits. Four flat
   rectangles of near-white would have been four identical chips, and a chip
   is the only thing on this screen telling the teacher what "Ocean" means.
   The values are the resolved ones, written out — a swatch for the grove
   theme cannot read grove's own variables, because the page is currently
   wearing a different theme. */
.swatch[data-t="sunrise"]{background:linear-gradient(180deg,#ebebea 0 58%,#ffffff 58% 100%)}
.swatch[data-t="meadow"]{background:linear-gradient(180deg,#e3e0d9 0 58%,#fbf9f5 58% 100%)}
.swatch[data-t="ocean"]{background:linear-gradient(180deg,#dcdfe1 0 58%,#f7f8f9 58% 100%)}
.swatch[data-t="grove"]{background:linear-gradient(180deg,#d9ded1 0 58%,#f7f8f5 58% 100%)}

.itemlist{display:grid; gap:8px; margin-bottom:12px}
.itemrow{display:grid; grid-template-columns:52px 1fr 96px 40px; gap:8px; align-items:center}
.itemrow input{
  width:100%; font:inherit; font-size:14px; padding:9px 10px; border-radius:10px;
  border:1px solid var(--card-edge); background:var(--card); color:var(--ink);
}
.itemrow input.emoji{text-align:center; font-size:18px}
.itemrow input.delta{text-align:center; font-weight:800}
.itemrow button{
  border:1px solid var(--card-edge); background:transparent; color:var(--bad);
  border-radius:10px; height:36px; cursor:pointer; font-size:15px;
}

@media (max-width:640px){
  .grid{gap:8px; padding:2px 8px 8px}
  /* No padding: the stage is meant to run full-bleed to the card edge, and
     every pixel taken here comes straight off the pet. */
  .card{border-radius:14px}
  .topbar{opacity:.75; gap:8px; padding:8px 10px}
  .brand{font-size:14px}
  .actions{gap:6px}
  .btn{padding:8px 10px; border-radius:10px}
  /* Six controls on a 407px bar left the class name at "C." — a class name
     truncated to one letter reads as a bug, not as a name. Undo is the widest
     of the six and the only one whose glyph carries it alone, so the word goes
     there and the name gets its back. */
  #btnUndo .undo-txt{display:none}
  /* Then the students button arrived as the seventh and ate that room back —
     measured 2026-10-10, the brand was down to 54px, i.e. "C…" again. Two
     things go. First the paw: it is the one part of the brand that is not the
     name. Then the width of the two emoji buttons — 👥 and 🏆 lay out 24px
     wide where ↺ and ⛶ are 16, so those two were 44px in a row where every
     other button is 36; capping them at their neighbours' width is closer to
     uniform than what was there before, not a squeeze. Together those give the
     name back 43px, which is the difference between "C…" and a class name. */
  .paw{display:none}
  .actions{gap:5px}
  #btnStudents,#btnBoard{padding:8px 6px}
  .itemrow{grid-template-columns:44px 1fr 74px 36px}
  /* The shop row is five things deep on a wall at 420px. The label is the
     only part that can give — it ellipsizes; everything else keeps its size.
     The stock note stays: "Sold out" is the explanation for a dimmed button
     and does not get to be the first thing to go. */
  .shoprow{gap:6px; padding:8px 10px}
  .shoprow .snote{font-size:11px}
  .shoprow .scost{min-width:30px}
  .shoprow .sredeem{padding:7px 9px; font-size:12px}
  .itemrow.shop{grid-template-columns:40px 1fr 58px 58px 34px}
  /* The weekly editor is the one row in Setup that cannot survive five across
     on a phone: squeezes to "Great h…"/"Hom" made the selects unreadable, and
     a native select clips rather than wraps, so there was nothing to ellipsize
     into. Stack instead — emoji and ✕ keep their columns full-height, the
     name, the item and the mode each get a line of their own. Taller rows, but
     this panel is a once-a-week visit and every word is legible.
     Children in order: emoji input, label input, item select, mode select, ✕. */
  .itemrow.weekly{
    grid-template-columns:40px 1fr 34px; grid-template-rows:auto auto auto;
    gap:6px; align-items:center;
  }
  .itemrow.weekly > :nth-child(1){grid-column:1; grid-row:1 / 4}
  .itemrow.weekly > :nth-child(2){grid-column:2; grid-row:1}
  .itemrow.weekly > :nth-child(3){grid-column:2; grid-row:2}
  .itemrow.weekly > :nth-child(4){grid-column:2; grid-row:3}
  .itemrow.weekly > :nth-child(5){grid-column:3; grid-row:1 / 4}
  .itemrow.weekly select{font-size:12px; padding:8px 6px}
  .wrow{gap:8px; padding:8px 10px}
  .wrow .wnm{font-size:13px}
  .wrow .wval{font-size:13px}
  /* The two doors hold their side-by-side read at 420px — each is a label
     plus a number, ~150px at the smallest sizes, and the caret. The medals
     shrink to roughly three or four across so the portraits stay worth
     looking at. */
  .sfact{gap:6px; padding:10px 12px}
  .sfact .sfk{font-size:12px}
  .bgrid{--mw:clamp(56px,21vw,88px)}
  .trow.ladder{padding:8px 10px}
}
