/* ============================================================
   Chewtoy's Dugout — Blitz Builder · "Night Pitch" (2026-09)

   ONE stylesheet for /blitz-builder/, replacing eight legacy files
   (builder-core, builder-cards, builder-ui, builder-sprue, builder-roster,
   builder-star, team-builder, builder-responsive — ~16,000 lines).

   WRITTEN FROM SCRATCH, NOT PORTED. The legacy tree terminated a
   `flex: 1; min-height: 0` chain in a fixed-height shell that no longer
   exists, so a mechanical port would have carried that chain into a document
   that scrolls — every breakpoint would have had to be re-derived anyway. What
   it could not lose is COVERAGE: the class names, which are contract because
   js/builder-v2/ui/** writes them at runtime. tools/contract/builder-css.mjs
   (npm run gate:builder-css) harvests every class the template and the JS put
   in the DOM and fails if any has no rule here. That gate is the proof.

   LAYOUT MODEL   REWORKED 2026-09-07
   ------------
   Document scroll. No internal scroller, no 100dvh.

   ONE COLUMN AT EVERY WIDTH, and #stickyBottomBar is a FIXED FULL-WIDTH BAR
   at every width. Owner: "they need to be across the whole page. move the
   summary bit to a bar at the bottom that is sticky. then spread everything
   out across the whole width."

   What that replaced: a grid of [ stages | 320px summary ] above 1000px, with
   the bar treatment only below it. The 320px aside cost the roster a quarter
   of the page for a column of five short chips, and the roster is the one
   screen on this site that genuinely wants width   a base sprue is a LANDSCAPE
   drawing, so every card is wider than it is tall and column count is what
   decides how much of the team you can see at once. Laid along the bottom the
   same chips fit in one row with room to spare, and the roster gets the lot.

   The builder page WIDENS --wrap itself (to 1680px) rather than carrying a
   second token, because everything that decides where the edge of the page is
   reads that one   including the steel frame's --bay-gutter. See section 1.

   The bench (#positionalPickerSticky) is a streamlined strip across the top of
   the roster step (section 6), and still a bottom sheet on phones, toggled by
   body.ctd-bench-open.

   Tokens only — no raw colour, nothing under 12px, flat.
   ============================================================ */

/* ---------- the pitch, turned down ----------
   HALF the storefront's, the same as every other effect on this page. The
   user is AIMING at these rows rather than browsing them, so the floor's
   job here is depth and nothing else.

   This is the per-page EFFECT BUDGET from DESIGN-SYSTEM.md applied to the one
   token that can express it. The floor is fixed behind the whole document, so
   it cannot be enqueued per page like a stylesheet can — but --ground-amp is
   read off the layer, and the layer is a child of body, so a page that loads
   this file turns the pitch down for itself and nothing else. */
body { --ground-amp: 0.3; }  /* superseded in §22 — the builder stands on the site floor now */


/* ============================================================
   1 · PAGE SHELL
   ============================================================ */

/* Back/Next's height. Declared on the PAGE scope rather than on .builder-app
   because the toast host is a body child OUTSIDE the app and has to clear this
   bar too — and a var that is out of scope makes the whole calc invalid, which
   drops the property to `auto` with no error anywhere. (That is precisely how
   the toast host ended up pinned to the top of the viewport the first time
   this was written.) builder.css is enqueued on builder pages only, so
   nothing else in the site sees it. */
/* --mwnav-h retired 2026-09-08: the bar is ONE row, sized by --actionbar-h. */

/* THE WHOLE PAGE IS WIDER HERE, NOT JUST THE SHELL (owner, 2026-09-07:
   "spread everything out across the whole width").

   --wrap is 1200px site-wide because that is a comfortable measure for
   READING   the shop, a product page, the FAQ. This page is not read, it is
   worked in, and the two things on it that want width want it for the same
   reason: a base sprue and a skill sheet are LANDSCAPE drawings, so more
   columns is the only way to see more of the team at once.

   IT IS --wrap ITSELF AND NOT A SECOND TOKEN, and that is the correction that
   matters. The first cut added a --builder-wrap and applied it to the builder
   bar and the shell, leaving --wrap at 1200   which put the content straight
   under the STEEL FRAME: the vertical girders sit at --bay-gutter, and
   tokens.css derives that from --wrap (`calc(50% - var(--wrap) / 2)`). Widen
   the content without widening the page and the frame stays where it was, so
   a 1537px roster runs under both girders and the first and last column are
   behind a steel column. Everything that decides where the edge of this page
   is reads --wrap; there is only one number to move.

   1680px rather than 100% because a roster is still a grid of cards and a
   3440px monitor would give it eleven columns of unreadable type   the printed
   tag codes are sized off the card width (see the note on .roster-cards-grid).

   builder.css loads on builder pages only, so this reaches nothing else. It is
   declared on BOTH selectors because .ctd-scope re-declares the token set on
   the header and footer, and a root-only override misses the chrome. */
html.ctd-v3, .ctd-scope { --wrap: 1680px; }

/* ---- THE FRAME NEEDS ROOM OUTSIDE THE MEASURE, AND --wrap HAS TO LEAVE IT ----

   THE BUG THIS FIXES, because it is not obvious from either file on its own.
   Above 1360px the steel frame hangs its layers OUTSIDE the content measure,
   each with its own expression off --wrap rather than off a shared token:

     .ctd-girder--l/--r   calc(50% - var(--wrap) / 2 - var(--girder-w))
     .ctd-joint--l/--r    right: calc(var(--joint-stem) * -1), i.e. 40px
                          further out again, reaching for the corner
     --bay-gutter         calc(50% - var(--wrap) / 2)

   So the frame reserves --girder-w + --joint-stem = 80px each side, and at
   --wrap: 1200 with a 1360px gate it has exactly 80px   the site's measure and
   the frame's gate are a tuned pair, which is invisible until you move one.
   Setting a flat 1680 breaks the pair for every viewport under 1760: the
   offsets go negative, the right-hand girder and its joint hang off the page,
   and the document gets a horizontal scrollbar (88px of it on a 1585px
   window, measured).

   A FLOOR ON EACH OFFSET WAS THE FIRST FIX AND IT WAS THE WRONG ONE. It kept
   the frame on screen, but the joint's negative offset is deliberate geometry
   (it reaches past the girder to make the corner), so clamping it to 0 is
   overriding the ornament's own drawing to compensate for a page that is too
   wide. Fix the width instead   there is one number, and the frame's own
   reserve is the constraint on it.

   176px is 2 x 88: the frame's 80px each side plus 8px of slack for the
   scrollbar, which 100vw includes and clientWidth does not. At 1360 this
   computes to 1199   within a pixel of the 1200 the frame was tuned against,
   so the frame's geometry at its gate is unchanged. Only below the gate is
   the reserve dropped, because there are no girders down there to clear. */
@media (min-width: 1360px) {
  html.ctd-v3, .ctd-scope { --wrap: min(1680px, calc(100vw - 176px)); }
}

.team-builder-page { display: block; }

.builder-app {
  display: block;
  /* The builder bar's height, in ONE place: it sets the bar's own min-height
     and the sticky summary's top offset, which must clear it. Two literals
     would drift the first time the bar's padding changes. */
  --builderbar-h: 56px;
}

/* The old page scroller. Now a plain wrapper: the document scrolls, and
   ui/wizard.js still uses #appBody as the rail's fallback anchor. */
.app-body { display: block; }

.builder-shell {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-6);
  align-items: start;
  /* The bottom padding CLEARS THE FIXED BAR at every width. One row's worth:
     Back, status, chips, total and the primary share --actionbar-h (declunk
     2026-09-08; it was two rows and 126px). */
  padding-block: var(--s-6) calc(var(--actionbar-h) + var(--s-8));
}

.app-stages { min-width: 0; display: flex; flex-direction: column; gap: var(--s-6); }

/* ---------- builder bar (was .app-header) ----------
   A shelf under the site header, cut from the same materials as the rest of
   the page: square --r-1 corners, display-face labels, the lit top edge every
   panel on this site carries, and the one bone-on-ink fill the whole design
   system uses for "this is the chosen one". It does NOT borrow the header's
   riveted nav plates — those tokens belong to the nav (and the nav alone), and
   a second rack of plates 60px under the first would read as two headers. */
/* THE SITE HEADER SCROLLS AWAY ON THE BUILDER PAGE (owner decision
   2026-09-08). The timber nav is 88px and it was pinned above a 57px builder
   bar, so the tool started 184px down on every screen and the roster step had
   136px of card showing on a 768px laptop. This is a workshop, not a shop
   page: the builder bar (title, beats, Save/Load, the Back arrow home) is the
   chrome that needs pinning, and the header is one scroll up. builder.css is
   enqueued on builder pages only, so no other page loses its sticky header.
   Every sticky offset in this file is measured from the builder bar alone. */
body.ctd-app-page .ctd-header { position: relative; }
/* `relative`, NOT `static`: the header's boarding and flanges are absolutely
   positioned children with top/bottom set, so a static header would hand them
   the initial containing block and the boards would paint over the whole page
   (they did, for one render, on 2026-09-08). */
.ctd-builderbar {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
  background: var(--bg-alt);
  border-bottom: var(--bw) solid var(--line-2);
  box-shadow: var(--fx-rim);
}
.ctd-builderbar__inner {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  min-height: var(--builderbar-h);
  padding-block: var(--s-2);
  flex-wrap: wrap;
}
.ctd-builderbar__id { display: flex; align-items: center; gap: var(--s-3); min-width: 0; }
.ctd-builderbar__title {
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--track-display);
  font-size: var(--fs-18);
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Title left, beats centred, Save/Load right — the beats used to be glued to
   the end of the title with the whole right half of the bar empty, which read
   as one run-on line rather than as three things. */
.ctd-builderbar__steps { flex: 1; min-width: 0; display: flex; justify-content: center; }
.ctd-builderbar__steps:empty { display: none; }
.ctd-builderbar__actions { display: flex; align-items: center; gap: var(--s-2); }
.ctd-builderbar__actionlabel { font: inherit; }

/* The back arrow was a bare glyph on a flat strip with no edge to say it was
   a control. Same square outline as everything else on the bar. */
.ctd-builderbar .header-back-btn {
  width: var(--control-h-sm); height: var(--control-h-sm);
  border-radius: var(--r-1); border-color: var(--line-2); color: var(--text-2);
}
.ctd-builderbar .header-back-btn:hover { background: var(--surface); border-color: var(--bone-500); color: var(--text); }

/* Save / Load. They shipped as .ctd-pill — the FILTER-CHIP primitive, hairline
   and pill-round — so the app's two most permanent controls read lighter than
   a shop facet. Restyled here (not in the markup) to the .ctd-btn recipe every
   other button on the site uses: square corners, display face, uppercase, rim. */
.ctd-builderbar__actions .ctd-pill {
  min-height: var(--control-h-sm);
  padding: 0 var(--s-4);
  border-radius: var(--r-1);
  border-color: var(--line-2);
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-display);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: var(--fs-14);
  box-shadow: var(--fx-rim);
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.ctd-builderbar__actions .ctd-pill .ctd-i { color: var(--text-3); }
.ctd-builderbar__actions .ctd-pill:hover { background: var(--surface-2); border-color: var(--bone-500); color: var(--text); }
.ctd-builderbar__actions .ctd-pill:hover .ctd-i { color: var(--text-2); }
/* The Load button stays lit while its panel is open — the state was previously
   announced by aria-expanded alone, with nothing visible. */
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"] { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"] .ctd-i { color: var(--ink-950); }

/* The back button only appears once a pathway is entered (ui/stage.js adds
   .show-back to #appHeader). */
.header-back-btn { display: none; }
.header-center { display: flex; min-width: 0; }
.header-right { display: flex; align-items: center; margin-left: auto; }
.header-actions { display: flex; align-items: center; gap: var(--s-2); }
.header-title { display: inline-block; }
.header-save-btn, .header-load-btn { white-space: nowrap; }
#appHeader.show-back .header-back-btn { display: inline-flex; }

/* ============================================================
   2 · WIZARD RAIL
   The rail carries BOTH class sets: .ctd-steps / .ctd-step are the shared
   component (components.css), .v2-stepper* are what ui/wizard.js queries.
   ============================================================ */
.v2-stepper { display: flex; align-items: center; gap: var(--s-1); flex-wrap: wrap; }
.v2-stepper[hidden] { display: none; }

/* The beats. .ctd-step (components.css) draws them as hairline PILLS, which is
   the filter-chip shape — five of them in a row read as a facet list, not as a
   route with a position on it. These rules win on source order and give the
   rail the site's control shape instead: a square outlined numeral, a display
   label, and the bone-on-ink fill that means "chosen" everywhere else (the
   active nav plate, .ctd-seg__opt--on, .ctd-pill--on).
   The numeral chip is what carries the state, so the row still reads at a
   glance when the labels collapse under 769px. */
.v2-stepper__step {
  display: inline-flex; align-items: center; gap: var(--s-2);
  min-height: var(--control-h-sm);
  padding: 0 var(--s-3);
  border: var(--bw) solid transparent;
  border-radius: var(--r-1);
  background: none;
  color: var(--text-3);
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
/* A future beat is unreachable, not faded out — 0.55 on --text-3 was below
   the contrast floor and the row lost its shape after step 2. */
.v2-stepper__step:disabled { opacity: 1; cursor: default; }
.v2-stepper__step:not(:disabled):hover { color: var(--text); border-color: var(--line-2); background: var(--surface); }

.v2-stepper__num {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; flex: none;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-12);
  font-variant-numeric: tabular-nums; color: var(--text-2);
}
.v2-stepper__num .ctd-i { width: 14px; height: 14px; }
.v2-stepper__label {
  font-family: var(--font-display); font-weight: 500;
  text-transform: uppercase; letter-spacing: var(--track-caps);
  font-size: var(--fs-13);
}

/* Done: the tick is the whole signal, in the same green the rest of the app
   uses for a filled slot. */
.v2-stepper__step.is-done { color: var(--text-2); }
.v2-stepper__step.is-done .v2-stepper__num { border-color: var(--ok); color: var(--ok); }

/* Current: the plate is stamped, and the numeral inverts on it. Restated here
   because .ctd-step--on lives in components.css and this file loads after it. */
.v2-stepper__step.is-current {
  background: var(--bone-100); border-color: var(--bone-100); color: var(--ink-950);
  box-shadow: var(--fx-rim-strong);
}
.v2-stepper__step.is-current .v2-stepper__num {
  background: var(--ink-950); border-color: var(--ink-950); color: var(--bone-100);
}

.v2-stepper__rule { width: var(--s-3); height: var(--bw); background: var(--line-1); flex: none; }

/* The step-change entry animation (ui/wizard.js re-adds the class). */
.v2-step-anim { animation: ctd-step-in var(--t-base) var(--ease); }
@keyframes ctd-step-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .v2-step-anim { animation: none; } }

/* ============================================================
   2B · WIZARD STEP GATING
   ui/wizard.js stamps data-mwstep = team | roster | tags on #builderApp.
   Each value puts ONE workspace on screen and takes the rest off it. That
   is the whole wizard: the JS only writes the attribute. (The former first
   value, `format`, went with the size step — owner rev 2026-09-07: one
   builder, the roster count is the squad size.)

   THIS BLOCK IS LOAD-BEARING, and it is the bug this file shipped with.
   The eight legacy stylesheets carried the [data-mwstep] contract
   (builder-responsive.css:420-660); the Phase 4 rewrite did not bring it
   across, so between then and 2026-09-05 the live builder painted every
   step at once — a ~6,600px page with the team grid, the bench, the roster
   and the progress-skill picker all stacked in one 792px column. gate:builder-css cannot see the loss: it harvests
   CLASS names and every rule here keys off an attribute.

   Why !important. ui/team/index.js's showSections() writes INLINE display
   on #presetPicker, #rosterHeader, #btnBenchSheet, #positionalPickerSticky,
   .roster-cards-wrapper, #sectionFillSprue and #stickyBottomBar — it has to,
   because it runs before the wizard has stamped the attribute and a cleared
   inline style would flash the whole app in on first paint. An inline style
   outranks every selector, so the step gate is the one place in this file
   allowed to outrank it back.
   ============================================================ */

/* The stage banner repeats the builder bar's title and the team page's own
   copy, and the pathway hero belongs to the landing, not to a step in
   progress. */
.builder-app--v2[data-mwstep] #stageBuilder > .builder-intro,
.builder-app--v2[data-mwstep] .builder-hero {
  display: none !important; /* lint-allow: important — beats showSections()'s inline display, see 2B */
}

.builder-app--v2[data-mwstep="team"] #rosterHeader,
.builder-app--v2[data-mwstep="team"] #btnBenchSheet,
.builder-app--v2[data-mwstep="team"] #positionalPickerSticky,
.builder-app--v2[data-mwstep="team"] #rosterScrollArea,

.builder-app--v2[data-mwstep="roster"] #presetPicker,
.builder-app--v2[data-mwstep="roster"] #sectionFillSprue,

.builder-app--v2[data-mwstep="tags"] #presetPicker,
.builder-app--v2[data-mwstep="tags"] #rosterHeader,
.builder-app--v2[data-mwstep="tags"] #btnBenchSheet,
.builder-app--v2[data-mwstep="tags"] #positionalPickerSticky,
.builder-app--v2[data-mwstep="tags"] .roster-cards-wrapper {
  display: none !important; /* lint-allow: important — beats showSections()'s inline display, see 2B */
}

/* THE BAR IS ONE ROW, and the step decides which primary it shows (declunk
   2026-09-08). Steps 1–2 go forward with Next ("Roster", "Skills"); step 3's
   forward IS the review, so Review order stands in for Next there. Step 1 has
   no roster yet, so its chips have nothing to say and only the total stays.
   Outside the wizard (the sprue and star pathways carry no data-mwstep) Back,
   the status and Next hide, leaving chips · total · Review. */
.builder-app--v2[data-mwstep="team"] .stat-chip:not(.price-stat) { display: none; }
.builder-app--v2[data-mwstep="team"] #btnReview,
.builder-app--v2[data-mwstep="roster"] #btnReview { display: none; }
.builder-app--v2[data-mwstep="tags"] .mw-nav__btn--next { display: none; }
.builder-app:not([data-mwstep]) .mw-nav__btn,
.builder-app:not([data-mwstep]) .mw-nav__status { display: none; }

/* ============================================================
   3 · STAGES, INTRO, HERO
   ============================================================ */
/* ui/stage.js toggles `.active` (and `.exiting` during the crossfade) — NOT
   `.is-active`. Getting this wrong leaves every stage display:none and the
   whole app blank, and gate:builder-css cannot see it: both class names have
   rules, so coverage is satisfied while the behaviour is broken. This is the
   half of "no rule was lost" that only the smoke run can prove. */
.stage { display: none; }
.stage.active { display: flex; flex-direction: column; gap: var(--s-6); }
.stage.exiting { display: block; opacity: 0; transition: opacity var(--t-fast) var(--ease); }
.stage-content { display: flex; flex-direction: column; gap: var(--s-6); }
.stage-padded { padding-block: var(--s-2); }

.builder-intro {
  background: var(--surface);
  border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2);
  padding: var(--s-5) var(--s-6);
}
.builder-intro__content { display: flex; flex-direction: column; gap: var(--s-3); }
/* `.builder-intro__title` was an h3 with an icon in front of it. Both workshop
   mastheads carry a ctd_plate() nameplate now (2026-09-11, §21), which is the
   same heading the Blitz stage's start panel already used — so the rule went
   with the markup. The legacy stylesheets still carry their own copy; they do
   not load on a v3 page. */
.builder-intro__blurb { margin: 0; color: var(--text-2); max-width: var(--measure); }
.builder-intro__chips { display: flex; flex-wrap: wrap; gap: var(--s-2); }

.intro-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-pill);
  font-size: var(--fs-13); color: var(--text-2);
}
.intro-chip .ctd-i { color: var(--text-3); }
.intro-chip--price { color: var(--gold-300); border-color: var(--gold-600); }
.intro-chip--price .ctd-i { color: var(--gold-300); }
.intro-chip--accent { color: var(--accent-2); border-color: var(--violet-600); }
.intro-chip--accent .ctd-i { color: var(--accent-2); }

/* ---------- collapsible hero ---------- */
.builder-hero {
  background: var(--surface);
  border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2);
  overflow: hidden;
}
.builder-hero__toggle {
  width: 100%;
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3);
  min-height: var(--control-h);
  padding: var(--s-3) var(--s-4);
  background: var(--bg-alt);
  border-bottom: var(--bw) solid var(--line-1);
  color: var(--text);
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-16);
}
.builder-hero__toggle-label { display: inline-flex; align-items: center; gap: var(--s-2); }
.builder-hero__toggle-icon { transition: transform var(--t-fast) var(--ease); }
.builder-hero--collapsed .builder-hero__toggle { border-bottom: 0; }
.builder-hero--collapsed .builder-hero__toggle-icon { transform: rotate(180deg); }
.builder-hero--collapsed .builder-hero__body { display: none; }
.builder-hero__body { padding: var(--s-6); display: flex; flex-direction: column; gap: var(--s-6); }
.builder-hero__header { display: flex; flex-direction: column; gap: var(--s-2); }
.builder-hero__desc { margin: 0; color: var(--text-2); max-width: var(--measure); }

.builder-hero__pathways { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-4); }
.builder-hero__pathway {
  display: flex; flex-direction: column; gap: var(--s-3);
  padding: var(--s-5);
  text-align: left;
  background: var(--bg-alt);
  border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2);
  color: var(--text);
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.builder-hero__pathway:hover { border-color: var(--line-2); background: var(--surface-2); }
.builder-hero__pathway-icon { color: var(--accent-2); }
.builder-hero__pathway-name {
  margin: 0;
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-18);
}
.builder-hero__pathway-desc { margin: 0; color: var(--text-2); font-size: var(--fs-14); }
.builder-hero__pathway-included-label {
  margin: 0;
  font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps);
  color: var(--text-3);
}
.builder-hero__pathway-features { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.builder-hero__pathway-features li { display: flex; align-items: flex-start; gap: var(--s-2); font-size: var(--fs-13); color: var(--text-2); }
.builder-hero__pathway-features li .ctd-i { color: var(--text-3); flex: none; margin-top: 2px; }
.builder-hero__pathway-features--included li .ctd-i { color: var(--ok); }
.builder-hero__pathway-price {
  margin-top: auto;
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-20);
  color: var(--gold-300); font-variant-numeric: tabular-nums;
}
.builder-hero__pathway-cta {
  display: inline-flex; align-items: center; gap: var(--s-2);
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14); color: var(--accent);
}

.builder-hero__footer {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  flex-wrap: wrap;
  padding-top: var(--s-4);
  border-top: var(--bw) solid var(--line-1);
}
.builder-hero__status { margin: 0; color: var(--text-2); font-size: var(--fs-14); }
.builder-hero__resume-count {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px;
  border-radius: var(--r-pill);
  background: var(--accent); color: var(--on-gold);
  font-size: var(--fs-12); font-weight: 700; font-variant-numeric: tabular-nums;
}
.builder-hero__resume-count[hidden] { display: none; }

/* Entry states ui/index.js and ui/stage.js toggle on #builderApp. */
.builder-layout { display: block; }
.builder-layout[hidden] { display: none; }
.builder-main-content { min-width: 0; display: flex; flex-direction: column; gap: var(--s-6); }
.builder-preteam-intro .builder-layout { display: none; }
.builder-team-ready .builder-hero__pathways { display: none; }
/* Nothing to save before a pathway is open; Load stays (resume a build). */
.builder-app--v2.builder-preteam-intro.stage-builder .header-save-btn { display: none; }
.builder-tv-hidden .rt-cost,
.builder-tv-hidden .rt-th-cost,
.builder-tv-hidden .rpc-value { display: none; }

/* ============================================================
   4 · TEAM STEP · THE MARQUEE PAGE  (rendered by ui/team/render.js)
   ============================================================ */
/* Owner rev 2026-09-07: the size step is gone, so this first page carries
   what it used to teach — the price bands (a spec strip, not a control),
   the extras line, what is in the box — above the team quick-fill grid.
   One column by default; the info rail docks to the right once the page has
   real room for it. */
.v2-format-stage { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s-6); align-items: start; }
.v2-format-head { display: flex; flex-direction: column; gap: var(--s-1); }
.v2-format-head__sub { margin: 0; color: var(--text-2); max-width: var(--measure); font-size: var(--fs-14); }
/* THE TWO COLUMNS BELONG TO THE STAGE, NOT TO MAIN. They were on
   .v2-format-main, whose only children are the size step and the extras
   footnote — so the footnote became column 2, the size cards were squeezed
   into a 468px column and stacked, and .v2-format-rail (the actual side
   column) fell to the full width underneath. That single misplaced
   declaration is why "squad sizes cards are stacked". */
.v2-format-main { display: flex; flex-direction: column; gap: var(--s-4); min-width: 0; }
.v2-format-rail { display: flex; flex-direction: column; gap: var(--s-4); min-width: 0; }
.v2-format-panel {
  background: var(--surface); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2); padding: var(--s-4);
}
.v2-format-panel__title {
  display: flex; align-items: center; gap: var(--s-2); margin: 0 0 var(--s-3);
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-16); color: var(--text);
}
.v2-format-panel__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
.v2-format-panel__list li { display: flex; align-items: flex-start; gap: var(--s-2); font-size: var(--fs-14); color: var(--text-2); }
.v2-format-panel__list li .ctd-i { color: var(--text-3); flex: none; margin-top: 2px; }
.v2-format-panel--how .v2-format-panel__steps { margin: 0; padding-left: var(--s-5); display: flex; flex-direction: column; gap: var(--s-2); color: var(--text-2); font-size: var(--fs-14); }

/* THE PRICE BANDS. One card per band the pricing ladder describes (£30 up to
   11 players, £40 from 12 — read off cfg by engine/formats.js priceTiers(),
   never written here). Plain <div>s: the roster picks the band, so these
   must NOT look like the old size cards a customer could click. Same
   materials as the info panels so they read as a spec sheet. */
 /* THE PRICE LADDER, ONE CHIP PER RUNG (price rev 2026-09-08). The strip has
   been three shapes now and each one was right for the ladder underneath it:
   two tall spec cards for two flat bands, then two slim rows when the first
   screen needed the height back, and now seven chips because the price scales
   per player and there are seven answers to "what will mine cost?".

   auto-fit with a 124px floor puts all seven on one row at desktop width, four
   at 768 and two on a phone — no breakpoint, and it degrades to whatever the
   ladder actually holds (a two-band ladder would simply draw two wide chips). */
.v2-tier-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(124px, 1fr)); gap: var(--s-2); }
.v2-tier {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  padding: var(--s-2) var(--s-3);
  background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-1);
}
.v2-tier__range {
  font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps);
  color: var(--text-3); white-space: nowrap;
}
.v2-tier__price {
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-20);
  color: var(--gold-300); font-variant-numeric: tabular-nums; line-height: 1.1;
}
.v2-tier__spec { display: flex; flex-wrap: wrap; gap: 0 var(--s-2); }
.v2-tier__spec-row { display: inline-flex; align-items: center; font-size: var(--fs-12); color: var(--text-3); white-space: nowrap; }
.v2-tier__spec-row + .v2-tier__spec-row::before { content: '\00b7'; margin-right: var(--s-2); color: var(--line-2); }
/* One line under the ladder carrying what the rungs have in common. */
.v2-tier-note { margin: 0; font-size: var(--fs-13); color: var(--text-3); }
.v2-tier-note .ctd-i { vertical-align: -2px; margin-right: var(--s-1); }

/* THE EXTRAS COPY FOLDS (declunk 2026-09-08). Two paragraphs of pricing
   detail — the Journeyman clause, the +6/+12/+18 steps — sat between the
   bands and the team grid on the first screen. They are true and they stay,
   behind one line a customer can open; the roster footnote and the pool
   meter repeat the facts at the moment they apply. (The 10/16 quick-fill
   radio that used to sit here is retired: a tile loads the 16-set and the
   loaded toast offers the 10-player version.) */
.v2-extras { border: var(--bw) solid var(--line-1); border-radius: var(--r-1); background: var(--surface); }
.v2-extras__summary {
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-2) var(--s-4); min-height: var(--control-h-sm);
  font-size: var(--fs-13); color: var(--text-2); cursor: pointer; list-style: none;
}
.v2-extras__summary::-webkit-details-marker { display: none; }
.v2-extras__summary::after { content: '+'; margin-left: auto; font-family: var(--font-display); font-size: var(--fs-16); color: var(--text-3); }
.v2-extras[open] .v2-extras__summary::after { content: '\2212'; }
.v2-extras__body { display: flex; flex-direction: column; gap: var(--s-2); padding: 0 var(--s-4) var(--s-3); }
/* A sentence, not a row: display:flex here made the <strong> and the text
   nodes into columns and broke the line into four fragments. The icon rides
   inline at the start of the paragraph instead. */
.v2-size-extras-line { margin: 0; display: block; font-size: var(--fs-13); color: var(--text-3); line-height: 1.5; }
.v2-size-extras-line .ctd-i { vertical-align: -2px; margin-right: var(--s-1); }

.v2-step-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px var(--s-3); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2);
  font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps);
  color: var(--text-3);
}
.v2-step-chip--done { border-color: var(--ok); color: var(--ok); }

.v2-modal-verb { text-transform: none; }

/* ============================================================
   5 · TEAM PICKER
   ============================================================ */
/* The search rides above the grid while it scrolls — 30 crests is more than
   one screen and the filter is the point of the step. */
.v2-team-search {
  position: sticky; z-index: 1;
  top: calc(var(--builderbar-h) + var(--s-2));
  display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap;
  padding: var(--s-3) 0;
  background: var(--bg);
}
.v2-team-search input { flex: 1 1 220px; max-width: 420px; }
.v2-team-search__count { font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; margin-left: auto; }

.v2-team-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: var(--s-3); }
.v2-team-grid__loading { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s-2); color: var(--text-3); padding: var(--s-6); }

.v2-team-tile {
  display: flex; flex-direction: column; align-items: center; gap: var(--s-2);
  padding: var(--s-3);
  background: var(--surface); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2); color: var(--text);
  transition: border-color var(--t-fast) var(--ease);
}
.v2-team-tile:hover { border-color: var(--line-2); }
.v2-team-tile.is-selected, .v2-team-tile.is-active { border-color: var(--accent); background: var(--surface-2); }
/* .is-pending — chosen, waiting on the confirm that swaps the roster. */
.v2-team-tile.is-pending { border-color: var(--accent-2); }
.v2-team-tile__crest {
  width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
  border-radius: var(--r-1); background: var(--bg-alt); overflow: hidden;
}
.v2-team-tile__crest img { width: 100%; height: 100%; object-fit: contain; }
.v2-team-tile__crest.no-crest img { display: none; }
.v2-team-tile__name { font-size: var(--fs-13); text-align: center; color: var(--text-2); }
/* Not a crest tile: it is the OTHER way in, so it is the grid's first row —
   full width and one line tall, so it neither sits above the grid as a
   banner nor stretches the first row of crests to its own height (a tall
   two-column tile did exactly that). */
.v2-team-tile--scratch {
  grid-column: 1 / -1;
  flex-direction: row; align-items: center; flex-wrap: wrap; text-align: left;
  gap: var(--s-2) var(--s-4); padding: var(--s-3) var(--s-4);
  border-style: dashed;
}
.v2-team-tile--scratch:hover { border-color: var(--accent-2); }

.v2-scratch__icon { color: var(--accent-2); flex: none; }
.v2-scratch__title { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.v2-scratch__sub { font-size: var(--fs-13); color: var(--text-3); }
.v2-scratch__body { display: flex; flex-direction: row; flex-wrap: wrap; gap: 2px var(--s-3); align-items: baseline; min-width: 0; flex: 1 1 320px; }
.v2-scratch__cta { display: inline-flex; align-items: center; gap: var(--s-2); font-size: var(--fs-13); color: var(--accent); white-space: nowrap; margin-left: auto; }

/* THE BENCH'S TEAM BROWSER IS A POPOVER (declunk 2026-09-08). It used to be
   rendered into the strip itself — a flex item beside the positional cards,
   2,191px tall, because the 320px cap below was written on `__body`, which is
   each ROW'S text span. `.v2-bench-browser` is the anchor ui/team/index.js
   appends to the bench area (outside the sideways scroller, which would clip
   it); `__list` is the element that actually scrolls. */
.v2-bench-browser {
  position: absolute; top: calc(100% + var(--s-1)); left: var(--s-4); z-index: 6;
  width: min(540px, calc(100% - 2 * var(--s-4)));
}
.v2-team-browser { border: var(--bw) solid var(--line-2); border-radius: var(--r-2); background: var(--bg-alt); overflow: hidden; box-shadow: var(--shadow-2); }
.v2-team-browser__list { max-height: min(360px, 50vh); overflow-y: auto; } /* lint-allow: vh — popover cap, not a page lock */
.v2-team-browser__body { min-width: 0; display: flex; flex-direction: column; }
.v2-team-browser__row {
  width: 100%; display: grid; grid-template-columns: auto 1fr auto auto; gap: var(--s-3);
  align-items: center; text-align: left;
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--bw) solid var(--line-1); color: var(--text);
}
.v2-team-browser__row:last-child { border-bottom: 0; }
.v2-team-browser__row:hover { background: var(--surface-2); }
.v2-team-browser__row.is-active { background: var(--surface); }
.v2-team-browser__name { font-size: var(--fs-14); }
.v2-team-browser__lineup { font-size: var(--fs-12); color: var(--text-3); }
.v2-team-browser__check { color: var(--accent); }

.v2-creator {
  display: flex; flex-direction: column; gap: var(--s-2); align-items: center;
  padding: var(--s-4);
  border: var(--bw) dashed var(--line-2); border-radius: var(--r-2);
  color: var(--text); text-align: center;
}
.v2-creator:hover { border-color: var(--accent-2); }
.v2-creator__icon { color: var(--accent-2); }
.v2-creator__title { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-14); }
.v2-creator__sub { font-size: var(--fs-12); color: var(--text-3); }
.v2-creator__slots { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }

.v2-pending-banner {
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  background: var(--warn-bg); border: var(--bw) solid var(--warn);
  border-radius: var(--r-1); color: var(--text); font-size: var(--fs-14);
}

/* ============================================================
   6 · THE BENCH  (positional picker)
   ============================================================ */
.positional-section { display: flex; flex-direction: column; gap: var(--s-4); position: relative; }

/* The quick-fill team preset picker (step 2), rendered into #presetPicker. */
.preset-picker-inline { display: flex; flex-direction: column; gap: var(--s-4); }
.preset-picker-inline[hidden] { display: none; }

.section-lock-overlay {
  position: absolute; inset: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--s-2);
  background: var(--scrim); /* lint-allow: raw-colour — token */
  border-radius: var(--r-2); color: var(--text-2); text-align: center; padding: var(--s-6);
}
.section-lock-overlay[hidden] { display: none; }

.picker-header {
  display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap;
  padding-bottom: var(--s-2);
  border-bottom: var(--bw) solid var(--line-1);
}
.picker-header[hidden] { display: none; }
.picker-header h3 {
  display: flex; align-items: center; gap: var(--s-2); margin: 0;
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-18); color: var(--text);
}
.roster-header .roster-table-actions { margin-left: auto; display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.roster-count { font-size: var(--fs-14); color: var(--text-2); font-variant-numeric: tabular-nums; }

.team-name-display-group { display: inline-flex; align-items: center; gap: var(--s-2); }
.team-name-display-group[hidden] { display: none; }
.team-name-display { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); color: var(--text); }
.team-name-display.is-placeholder { color: var(--text-3); font-style: italic; text-transform: none; font-family: var(--font-body); }
.team-name-display-group.is-empty .team-name-rename-btn { display: none; }
.team-name-rename-btn { display: inline-flex; align-items: center; gap: 6px; color: var(--link); font-size: var(--fs-13); }
.team-name-rename-btn[hidden] { display: none; }
.team-name-rename-btn__label { text-decoration: underline; }
.team-name-edit-group { display: inline-flex; align-items: center; gap: var(--s-2); }
.team-name-edit-group[hidden] { display: none; }
.team-name-edit-icon { color: var(--text-3); }
.preset-change-btn[hidden] { display: none; }


.show-tags-toggle, .show-tv-toggle, .cb-mode-toggle, .skill-traits-toggle {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
}
.show-tags-toggle input, .show-tv-toggle input, .cb-mode-toggle input, .skill-traits-toggle input {
  position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%);
}
.toggle-slider-small {
  width: 32px; height: 18px; border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); background: var(--bg-alt);
  position: relative; flex: none; transition: background var(--t-fast) var(--ease);
}
.toggle-slider-small::after {
  content: ''; position: absolute; top: 2px; left: 2px;
  width: 12px; height: 12px; border-radius: var(--r-pill);
  background: var(--text-3); transition: transform var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
input:checked + .toggle-slider-small { background: var(--accent); border-color: var(--accent); }
input:checked + .toggle-slider-small::after { transform: translateX(14px); background: var(--on-gold); }
input:focus-visible + .toggle-slider-small { outline: 2px solid var(--focus); outline-offset: 2px; }
.toggle-label { color: var(--text-3); display: inline-flex; }
.skill-traits-toggle__text { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-13); color: var(--text-2); }

/* ---------- the bench panel ---------- */
/* `overflow: visible` OVERRIDES `.ctd-bench` (components.css), and it is what
   lets the team popover out. The bench wears `.ctd-bench` for its surface and
   its radius, and that component clips — which is right for the shop's spares
   list, the other thing wearing it, and wrong here: `.v2-bench-browser` hangs
   at `top: 100%`, entirely outside this box, so the panel cut the whole list
   away. Nothing else needs the clip. The strip does its own (the sideways
   scroller below is `overflow-x: auto`), and the grid's --s-4 of padding keeps
   every card well inside the corner curve. */
.positional-picker-area { position: relative; overflow: visible; }
.positional-picker-area[hidden] { display: none; }
/* A STRIP ACROSS THE TOP, NOT A PANEL OF CARDS   REWORKED 2026-09-07.
   Owner: "we need the positional selectors to be much more streamlined and
   across the top."

   It was an auto-fill grid of 176px cards in a 380px-tall vertical scroller,
   which on a full-width page came out as two or three ROWS of tall cards   an
   eighth of the screen spent on a picker you use for ten seconds per player,
   sitting on top of the thing you are actually looking at.

   Now it is ONE ROW that scrolls SIDEWAYS. That is the right axis for this
   control: the number of positionals a team has is small and fixed (four to
   six, plus the creator), they are peers with no ranking, and a horizontal
   run of them reads as a palette   which is what it is. The page scrolls
   vertically, so a sideways scroller here cannot fight it.

   `overscroll-behavior: contain` stops a trackpad flick inside the strip from
   chaining to the document once it hits the end. `scrollbar-width: thin`
   rather than hidden: a scroller with no visible bar is a scroller people do
   not know is there, and there is genuinely more to see on a narrow window. */
.positionals-scroll-wrapper {
  overflow-x: auto; overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.positionals-grid {
  display: flex; align-items: stretch; gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  width: max-content; min-width: 100%;
}

/* THE SOURCE IS THE STRIP'S FIRST TILE (declunk 2026-09-08). The bench was
   two rows — a 65px toolbar (Step 2 chip, "Adding from", a hint) over the
   card strip — and the pair was sticky, 191px of every viewport on the step
   that needs the room. The rail says "Step 2" and the footnote says "mix any
   teams", so the bar's only content was the team switch: it is the first
   item in the row now, and `position: sticky; left: 0` keeps it in view while
   the strip scrolls sideways. */
.v2-bench-source {
  position: sticky; left: 0; z-index: 2;
  flex: 0 0 auto; width: 184px;
  display: flex; flex-direction: column; justify-content: center; gap: var(--s-1);
  padding: var(--s-2) var(--s-3);
  background: var(--bg-alt); border: var(--bw) solid var(--line-2); border-radius: var(--r-2);
  color: var(--text); text-align: left;
  box-shadow: var(--fx-rim);
  transition: border-color var(--t-fast) var(--ease);
}
.v2-bench-source:hover { border-color: var(--bone-500); }
.v2-bench-source[aria-expanded="true"] { border-color: var(--accent); }
.v2-bench-source__label { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.v2-bench-source__team { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
.v2-bench-source__team .ctd-i { flex: none; color: var(--text-3); }
.v2-bench-source__crest { width: 28px; height: 28px; flex: none; }
.v2-bench-source__name {
  flex: 1; min-width: 0;
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.v2-bench-group { display: contents; }

/* Phone bottom-sheet controls — hidden with a pointer, shown at <=768px. */
.builder-bench-toggle { display: none; }
.builder-bench-toggle[hidden] { display: none; }
.builder-bench-toggle__label { font: inherit; }
.builder-bench-close { display: none; position: absolute; top: var(--s-2); right: var(--s-2); z-index: 2; }

/* ---------- positional cards ----------
   STREAMLINED FOR THE STRIP (owner, 2026-09-07). The card kept every line it
   had   name, qty, a five-stat row, the full starting-skill list and a
   meta line   which is what made it 130px tall. In a horizontal palette the
   job changes: you are picking between four things you already know, so the
   card only has to be identifiable and show the two numbers that change
   (how many you have, and its cost/cap).

   WHAT MOVED RATHER THAN WENT: the starting-skill list is the tallest thing
   on the card and the only wrapping text, so it is hidden HERE and still one
   click away in the info popover, which already carries it   the `i` button
   emits `data-desc` with base, description, full stat line, skills and both
   access letters. Nothing was deleted from the markup; renderPosCard still
   emits it all, so the popover and the phone sheet are unchanged. */
.pos-card {
  display: flex; flex-direction: column; gap: 5px;
  flex: 0 0 auto; width: 168px;
  padding: var(--s-2) var(--s-3);
  background: var(--surface); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2); color: var(--text); text-align: left;
  position: relative; overflow: hidden;
  transition: border-color var(--t-fast) var(--ease);
}
/* The skills AND meta lines are the popover's job in the strip (declunk
   2026-09-08: the meta line — "32mm · 50k · max 16 · G/as" — went too, and
   the one fact from it that matters at a glance, a 40mm base, is a badge on
   the header). Three lines became two, and the card 100px became ~70. */
.positionals-grid .pos-card-extra { display: none; }
/* A big guy's card is wider so the badge does not eat its name ("TR…"). */
.positionals-grid .pos-card[data-is-big-guy="1"] { width: 204px; }
.pos-card-size {
  flex: none; margin-left: auto;
  padding: 1px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--violet-600); color: var(--accent-2);
  font-size: var(--fs-12); font-variant-numeric: tabular-nums;
}
/* THREE LINES, AND NONE OF THEM WRAPS. That is the whole of "streamlined":
   the card was 137px tall in the strip because the header stacked to two lines
   and the meta wrapped to two more. A palette item has to be one glanceable
   block, so every line is nowrap and the one that can overflow (the meta, which
   is four bits joined by dots) is clipped with an ellipsis rather than allowed
   to reflow the row. The full string is in the info popover.

   The NAME is the line that must never be clipped, so it gets the room: qty
   first as a short fixed badge, name after it taking the rest. */
.positionals-grid .pos-card-header {
  gap: var(--s-2); padding-right: var(--s-5); min-width: 0;
  justify-content: flex-start;
}
.positionals-grid .pos-card-qty { flex: none; font-size: var(--fs-12); }
.positionals-grid .pos-card-name {
  font-size: var(--fs-14); line-height: 1.2;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.positionals-grid .pos-card-stats { gap: 5px; flex-wrap: nowrap; }
.positionals-grid .pcs-stat { gap: 2px; }
.positionals-grid .pcs-label { font-size: var(--fs-12); }
.positionals-grid .pcs-val { font-size: var(--fs-13); }
.positionals-grid .pos-card-extra { min-width: 0; }
.positionals-grid .pos-card-meta {
  display: block; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pos-card:hover { border-color: var(--line-2); }
.pos-card.is-disabled, .pos-card:disabled { opacity: 0.45; pointer-events: none; }
.pos-card.is-full { border-color: var(--ok); }
/* .maxed = this positional is at its own cap, or the roster is full. It has to
   read as unavailable BEFORE the click; the JS guard only toasts afterwards.
   The COUNT carries it, not the whole card: dimming the card meant a full
   roster read as a disabled bench (declunk 2026-09-08). */
.pos-card.maxed .pos-card-qty { color: var(--warn); }
.pos-card.maxed .pos-card-name { color: var(--text-3); }
.pos-card.maxed:hover { border-color: var(--line-1); }
.pos-card-stripe { position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line-2); }
.pos-card-header { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
.pos-card-name { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.pos-card-qty { font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; }
.pos-card-meta { font-size: var(--fs-12); color: var(--text-3); }
.pos-card-stats { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.pos-card-skills { display: flex; flex-wrap: wrap; gap: 4px; }
.pos-card-extra { font-size: var(--fs-12); color: var(--accent-2); }
.pos-card-info-btn { position: absolute; top: var(--s-2); right: var(--s-2); color: var(--text-3); }
.pos-card-info-glyph { pointer-events: none; }
.pcs-stat { display: inline-flex; align-items: baseline; gap: 4px; }
.pcs-label { font-size: var(--fs-12); color: var(--text-3); text-transform: uppercase; letter-spacing: var(--track-caps); }
.pcs-val { font-size: var(--fs-14); color: var(--text); font-variant-numeric: tabular-nums; }

/* The creator card is the strip's last item and it is a DIFFERENT SHAPE on
   purpose   it is the one thing in the row that is not a positional. It keeps
   the strip's height and takes a little more width so its two lines of copy
   do not wrap to four. */
.positionals-grid .custom-pos-card--empty { width: 176px; text-align: center; align-items: center; justify-content: center; }
.positionals-grid .custom-pos-card--empty .v2-creator__sub { display: none; }
.custom-pos-card { border-style: dashed; }
/* ---- AN EMPTY BAY WITH A BLANK PLATE ON IT (owner, 2026-09-13) ----
   This was the last dashed rectangle on the bench: a browser's default idea of
   "nothing here yet", sat in a rack of brass-tagged trays. A dashed border is
   the one mark on this screen that could not be made of anything.

   It is a vacant bay now, in the same language as an unfilled roster slot —
   the caution hatch a workshop paints on a space meant to be filled — with a
   blank brass plate waiting to be engraved, which is exactly what pressing it
   does. The plate is the SAME fitting as a roster card's bay tag, one size up,
   so the bench and the rack are visibly the same kit.

   The border stays violet: it is the one card here that is an action rather
   than a part, and that is worth saying. */
.custom-pos-card--empty {
  border-style: solid;
  border-color: color-mix(in srgb, var(--violet-600) 70%, transparent);
  background-image: var(--fx-tray-vacant), var(--fx-tooth), var(--fx-tooth-2);
  box-shadow: var(--fx-deck-edge);
  transition: box-shadow var(--t-base) var(--ease), border-color var(--t-fast) var(--ease);
}
.custom-pos-card--empty:hover,
.custom-pos-card--empty:focus-visible {
  border-color: var(--accent);
  box-shadow: var(--fx-tray-edge-lit);
}
/* The blank plate. A tool glyph struck into brass — the icon was floating on
   the tint before, which is the same "text on a rectangle" the roster cards
   were guilty of. */
.custom-pos-card--empty .v2-creator__icon {
  display: flex; align-items: center; justify-content: center;
  width: 34px; height: 28px;
  border-radius: 3px;
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 55%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--joint-lo) 90%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
  color: var(--ink-950);
}
.custom-pos-card--empty .v2-creator__title { text-shadow: var(--fx-plate-stamp); }
/* A configured custom positional is a PART, so it stops being a bay: it takes
   the bench's own card and keeps only the violet edge that says it is yours. */
.custom-pos-card[data-configured="1"] {
  border-style: solid;
  border-color: color-mix(in srgb, var(--violet-600) 80%, transparent);
}

.v2-custom-edit { position: absolute; top: var(--s-2); right: var(--s-8); color: var(--text-3); }

/* ============================================================
   7 · ROSTER
   ============================================================ */
.roster-scroll-area { display: flex; flex-direction: column; gap: var(--s-6); }
.roster-cards-wrapper[hidden] { display: none; }
/* ---- THE RACK (owner, 2026-09-13) ----
   The trays had nothing to stand on. Twelve of them floated straight on the
   page, which is why the region read as a void with rectangles in it however
   much material went onto the cards themselves — an object needs a surface
   before it reads as an object.

   So the grid gets a deck: tread plate, recessed, the trays racked on it. It
   is STEEL rather than the bench's boarding directly above, because two
   timber regions stacked would read as one, and because a rack of parts
   standing on a workshop floor is the true arrangement.

   The padding costs the columns about 6px each. `.roster-cards-grid` sizes its
   columns off the container, and its floor is 224px for a reason it documents
   at length (below 12px the printed tag codes go under the type minimum,
   invisibly, because the gate cannot see type inside an SVG) — at four across
   on a 1616px roster the cards are ~259px, so the floor is not in play. */
.roster-cards-wrapper {
  padding: var(--s-3);
  border-radius: var(--r-2);
  background-color: var(--bg);
  background-image: var(--fx-deck), var(--fx-tooth), var(--fx-tooth-2);
  background-size: auto, auto, auto, 3px 3px, 7px 7px;
  box-shadow: var(--fx-deck-edge);
}
/* THE 224px MINIMUM IS NOW A LEGIBILITY FLOOR, NOT A LAYOUT PREFERENCE.
   Each card draws a player sprue at its own full width (see the frame note
   below), and the printed tag code is 49 of the frame's 851 units — so a card
   224px wide renders that code at 12.9px and a card 208px wide renders it at
   12.0px, which is the smallest type this stylesheet is allowed
   (tools/contract/css-tokens.mjs, font-size<12). Lower this number and the
   codes on every base go under that floor SILENTLY: the gate reads CSS
   font-size declarations and cannot see type inside a generated SVG. If the
   grid ever needs narrower cards, the sprue view needs a scale rule first.

   FOUR ACROSS, NOT AS MANY AS FIT. The owner's 2026-08-10 call (four per row;
   eight was too cramped to scan stats and skills) lived in --roster-column-count,
   which ui/team/index.js still sets on this element. The v3 rewrite lost it to a
   bare auto-fill that put six 259px cards across the 1616px roster. The column
   minimum below is the LARGER of the legibility floor and a quarter of the row
   less one gap, so auto-fill can never place more than --roster-column-count
   columns, yet still drops to three, then two, once four 224px cards stop
   fitting -- with no viewport breakpoint to keep in step with --wrap. (A quarter
   less a whole gap, not three-quarters of one: the 3px of slack goes to 1fr and
   keeps a rounding error from turning four exact columns into three.) */
.roster-cards-grid {
  --roster-column-count: 4;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(max(224px, calc(100% / var(--roster-column-count) - var(--s-3))), 1fr));
  gap: var(--s-3);
}
/* Tablets: a quarter of 705px is under the 224px legibility floor, so the
   floor won and the roster went to ONE 705px column (a 9,700px page on an
   iPad). Two is the honest count there; the phone stays at one because a
   half of 327px is under the floor too. */
@media (max-width: 820px) { .roster-cards-grid { --roster-column-count: 2; } }
/* One blank frame is drawn for the next slot; this line stands in for the
   rest (up to six more frames used to follow it). */
.roster-slots-note { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--s-2); margin: 0; font-size: var(--fs-13); color: var(--text-3); }
.roster-slots-note .ctd-i { flex: none; }
.roster-table-view { overflow-x: auto; }
.roster-table-view[hidden] { display: none; }
.roster-table-split { display: grid; gap: var(--s-4); }
.roster-table-col { min-width: 0; }

/* ---------- player card ---------- */
.roster-player-card {
  display: flex; flex-direction: column; gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-2);
}
/* Ghost slots: the first ten are the smallest set we sell, the six past them
   are optional (the roster can stop anywhere from 10 to 16), so those read
   fainter still. */
.roster-player-card--empty { border-style: dashed; opacity: 0.7; }
.roster-player-card--optional { opacity: 0.45; }
.rpc-header { display: flex; align-items: center; gap: var(--s-2); }
.rpc-header--ghost { opacity: 0.6; }
.rpc-number {
  width: 32px; height: 32px; flex: none;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--r-1); background: var(--bone-100); color: var(--ink-950);
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-16);
  font-variant-numeric: tabular-nums;
}
.rpc-position { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.rpc-position--empty { color: var(--text-3); font-style: italic; text-transform: none; font-family: var(--font-body); }
.rpc-value { margin-left: auto; font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; }
.rpc-stats { display: flex; gap: var(--s-3); flex-wrap: wrap; }
.rpc-stats--ghost { opacity: 0.5; }
.rpc-stat { display: inline-flex; align-items: baseline; gap: 4px; }
.rpc-stat--ghost { opacity: 0.5; }
.rpc-stat-label { font-size: var(--fs-12); color: var(--text-3); text-transform: uppercase; letter-spacing: var(--track-caps); }
.rpc-stat-value { font-size: var(--fs-14); font-variant-numeric: tabular-nums; }
.rpc-stat-value--ghost { color: var(--text-3); }
.rpc-skills-area { display: flex; flex-direction: column; gap: 6px; }
.rpc-skills-area--ghost { opacity: 0.6; }
.rpc-skills-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); }
.rpc-skills-label { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.rpc-slot-cap { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }
.rpc-slot-number { font-size: var(--fs-12); color: var(--text-3); }
.rpc-skill-slots { display: flex; flex-wrap: wrap; gap: 4px; }
.rpc-skill-slots--ghost { opacity: 0.5; }
.rpc-skill-slot {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2);
  font-size: var(--fs-12); color: var(--text-2);
}
.rpc-skill-slot--ghost { border-style: dashed; color: var(--text-3); }
/* .base = printed on the player's own base (a starting skill); .added = a
   progress tag snapped on later. The distinction is the whole product, so it
   should not look the same. */
.rpc-skill-slot.base { border-color: var(--line-2); }
.rpc-skill-slot.added { border-style: dashed; border-color: var(--accent); }
.roster-player-card.big-guy-row { border-color: var(--violet-600); }
.roster-player-card.big-guy-row .rpc-number { background: var(--accent-2); }
.rpc-skill-slot--none { border-style: dashed; color: var(--text-3); font-style: italic; }
/* ---------- the card IS the sprue ----------
   Owner 2026-09-07: "when a card is added from the player, one of them cards
   are populated." The stats grid and the chip row above are gone from the card
   (they still dress the TABLE view); what sits between the header and the foot
   is now the frame the customer is actually sent — see ui/team/sprue-card.js.

   THE FRAME TAKES THE CARD'S WIDTH, and that is a deliberate choice rather
   than the easy one. js/ctd-parts.js publishes --pw on every part so a group
   can share one scale (.ctd-part-scale in parts.css), and a 40mm frame is 851
   units wide against the 32mm's 767 — 11% bigger, which is TRUE of the object.
   Honouring that inside a fixed grid cell needs either a container query (a
   new idiom in this stylesheet, for one component) or the number 851 written
   down somewhere it can silently drift from the generator. Neither earns 11%:
   the size is already carried twice, by the violet border a big guy's card has
   and by the mm stamp the frame casts into its own corner, and nobody compares
   base diameters by eyeballing two drawings in a grid. So each frame fills its
   card, which is what the scale note itself says to do for one part at one
   fixed width. */
.rpc-sprue { position: relative; margin: 2px 0 0; }
.rpc-sprue__frame > svg { width: 100%; height: auto; display: block; }

/* THE FRAME CARRIES NO CONTROLS (owner, 2026-09-14). The rename pencil that
   sat on the nameplate window from 2026-09-07, its hit rect and its `.rpc-fields`
   layer are all gone — a nameplate is the first TAG in the tag column, so a
   pencil on it reads as a control on a printed skill, which a 32mm base does
   not allow. The rename is on the header strip: §24.2 below. */
.rpc-f__badge .ctd-i { width: 62%; height: 62%; stroke-width: 2; }
/* The field, open. An opaque plate in the tags' own face (--font-varsity is
   what is printed on the part) carrying what you type, because the moulding
   under it still says the old name until the commit redraws it. Same plate as
   the star builder's focused field. */
.rpc-f--name {
  appearance: none;
  padding: 0 2px;
  border: 0;
  background: var(--ink-950); color: var(--bone-100);
  caret-color: var(--bone-100);
  font-family: var(--font-varsity), var(--font-display);
  font-size: var(--fs-14);
  line-height: 1;
  letter-spacing: var(--track-display);
  text-align: center;
  text-transform: uppercase;
  box-shadow: inset 0 0 0 2px var(--accent);
  outline: none;
  overflow: hidden;
}
.rpc-f--name::placeholder { color: var(--text-3); opacity: 1; }

/* THERE IS NO SIZE BADGE RULE HERE, and the deletion is the note. One was
   written — an mm pill in the frame's bottom-left corner — before the drawing
   was looked at: playerSprue() already casts 40MM / 32MM into the runner
   (--stamp-ink), top-left, so the badge sat two units from the same fact in a
   different typeface. The drawn one wins because it is true of the object.
   The size is still carried a second way, by the violet border on a big guy's
   card, and that one is a category cue rather than a repeated label. */

/* "+2 tags print on the progress sprue" — the question the drawing raises, and
   the one the chip row used to hide by listing every skill in a single row as
   if they all printed on the base. */
.rpc-sprue__overflow { margin: 0; font-size: var(--fs-12); color: var(--text-3); }
.rpc-sprue__foot { display: flex; justify-content: flex-end; }

/* An empty slot is the same frame with nothing written on it, so it must NOT
   also carry the ghost card's dashed border: the blank moulding already says
   "nothing here yet", and a dashed box round a drawing of a real object reads
   as a broken image. */
.roster-player-card--sprue.roster-player-card--empty { border-style: solid; opacity: 0.62; }

/* ============================================================
   7b · THE BASE PAINT PLANNER

   Owner 2026-09-07: "as an option i want a paint brush, this is where they can
   set their colour themes. it is important to note that the sprues come in
   black and thats purely for planning your scheme on the base."

   The panel sits between the roster header and the roster itself so the bases
   repaint IN VIEW while a colour is being chosen — which is the whole feature;
   a modal would cover the thing it is previewing. Behaviour and copy are in
   js/builder-v2/ui/paint.js, and nothing in here knows a colour.
   ============================================================ */
.roster-paint-btn { position: relative; }
.roster-paint-btn.is-active { color: var(--accent); border-color: var(--accent); }
/* A SCHEME IS ON, AND THE BUTTON HAS TO SAY SO. `.is-active` means the PANEL
   is open, which is not the same fact and does not survive a reload: a scheme
   persists (localStorage, ui/paint.js), so somebody who painted their bases
   green on Tuesday opens the builder on Wednesday to a green roster with
   nothing to explain it. body.ctd-painted is set whenever any zone is
   overridden and cleared by "As it ships", so this dot is the one place the
   answer to "why are my bases green?" is visible with the panel shut. */
body.ctd-painted .roster-paint-btn::after {
  content: ""; position: absolute; top: 2px; right: 2px;
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--accent);
}

.paint-panel {
  display: flex; flex-direction: column; gap: var(--s-3);
  padding: var(--s-4);
  background: var(--surface);
  border: var(--bw) solid var(--line-1); border-radius: var(--r-2);
}
.paint-panel[hidden] { display: none; }
.paint-panel__head { display: flex; align-items: center; gap: var(--s-2); }
.paint-panel__title {
  display: flex; align-items: center; gap: var(--s-2); margin: 0;
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14); color: var(--text-1);
}
.paint-panel__close { margin-left: auto; }

/* THE DISCLAIMER IS A FIRST-CLASS ELEMENT, not fine print. It is the one thing
   in this panel that must not be missed: the product is one colour, and a row
   of colour swatches implies otherwise until this sentence has been read. So
   it gets a rule in the accent, body-size text, and it is never collapsed,
   never a tooltip and never dismissible. */
.paint-panel__note {
  margin: 0; padding: var(--s-2) var(--s-3);
  border-left: 3px solid var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  font-size: var(--fs-13); line-height: 1.5; color: var(--text-2);
}
.paint-panel__note strong { color: var(--text-1); }

.paint-panel__group { display: flex; flex-direction: column; gap: 6px; }
.paint-panel__legend {
  font-family: var(--font-display); font-size: var(--fs-12); font-weight: 500;
  text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3);
}
.paint-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.paint-chip {
  padding: 4px var(--s-3); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); background: transparent;
  font: inherit; font-size: var(--fs-13); color: var(--text-2); cursor: pointer;
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.paint-chip:hover { color: var(--text-1); border-color: var(--text-3); }
.paint-chip.is-active { color: var(--accent); border-color: var(--accent); }
/* "As it ships" is the PRODUCT, so it reads as the baseline rather than as one
   scheme among ten — it is also the only chip that clears rather than sets. */
.paint-chip--ship {
  font-family: var(--font-display); text-transform: uppercase;
  letter-spacing: var(--track-caps); font-size: var(--fs-12);
}

.paint-wells { display: flex; flex-wrap: wrap; gap: var(--s-3); }
.paint-well { display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: pointer; }
/* A native colour input, sized down to a swatch. The two swatch pseudo-elements
   strip the browser's own inset border, which otherwise puts a light hairline
   round a dark swatch and makes it read as disabled. */
.paint-well__input {
  width: 42px; height: 28px; padding: 0; cursor: pointer;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  background: none;
}
.paint-well__input::-webkit-color-swatch-wrapper { padding: 2px; }
.paint-well__input::-webkit-color-swatch { border: none; border-radius: 2px; }
.paint-well__input::-moz-color-swatch { border: none; border-radius: 2px; }
.paint-well__input:hover { border-color: var(--text-3); }
.paint-well__label { font-size: var(--fs-12); color: var(--text-3); }

.paint-panel__more > summary { font-size: var(--fs-13); color: var(--text-2); cursor: pointer; padding: 2px 0; }
.paint-panel__more > summary:hover { color: var(--text-1); }
.paint-panel__more[open] > summary { margin-bottom: var(--s-2); }
.paint-panel__hint { margin: 0 0 var(--s-2); font-size: var(--fs-12); color: var(--text-3); line-height: 1.5; }

/* ---------- roster table ---------- */
.roster-table { width: 100%; min-width: 480px; border-collapse: collapse; font-size: var(--fs-14); }
.roster-table th, .roster-table td { padding: var(--s-2) var(--s-3); text-align: left; border-bottom: var(--bw) solid var(--line-1); }
.roster-table th {
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-caps); font-size: var(--fs-12); color: var(--text-3);
}
.rt-row:hover { background: var(--surface-2); }
.rt-number, .rt-cost, .rt-stat, .rt-th-num, .rt-th-cost, .rt-th-stat { font-variant-numeric: tabular-nums; text-align: right; }
.rt-th-num, .rt-number { text-align: left; width: 48px; }
.rt-position { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); }
.rt-skills { display: flex; flex-wrap: wrap; gap: 4px; }
.rt-actions { text-align: right; white-space: nowrap; }
.rt-th-act, .rt-th-pos, .rt-th-skills { text-align: left; }
.rt-th-act { text-align: right; }

.empty-roster-message, .empty-row-message {
  display: flex; align-items: center; justify-content: center; gap: var(--s-2);
  padding: var(--s-6); color: var(--text-3); font-size: var(--fs-14); text-align: center;
}
.v2-roster-footnote { margin: 0; font-size: var(--fs-12); color: var(--text-3); }

.v2-inline-edit {
  background: transparent; border: var(--bw) solid transparent; border-radius: var(--r-1);
  color: inherit; font: inherit; padding: 2px 4px; min-width: 4ch;
}
.v2-inline-edit:hover { border-color: var(--line-2); }
.v2-inline-edit:focus-visible { border-color: var(--focus); }
.v2-inline-edit--number { width: 5ch; text-align: center; font-variant-numeric: tabular-nums; }
.js-player-rename { cursor: text; }
.is-renamed { color: var(--gold-300); }

.v2-edit-skills { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-12); color: var(--link); }
.v2-edit-skills--row { margin-left: var(--s-2); }
.v2-edit-skills--bg { color: var(--accent-2); }
.btn-delete-player { color: var(--text-3); }
.btn-delete-player:hover { color: var(--err); }

/* ============================================================
   8 · PROGRESS TAGS  (step 4)
   ============================================================ */
.fill-sprue-section { display: flex; flex-direction: column; gap: var(--s-4); }
.fill-sprue-section[hidden] { display: none; }
.fill-sprue-section__head { display: flex; flex-direction: column; gap: var(--s-2); }
.fill-sprue-section__blurb { margin: 0; color: var(--text-2); font-size: var(--fs-14); max-width: var(--measure); }

.extra-tags-cards { display: flex; flex-direction: column; gap: var(--s-3); }
.extra-tags-empty { padding: var(--s-6); text-align: center; color: var(--text-3); }
.extra-tags-pool { display: flex; flex-wrap: wrap; gap: var(--s-2); }

.extra-tag-card { background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-2); overflow: hidden; }
.extra-tag-card__header {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); flex-wrap: wrap;
  padding: var(--s-3) var(--s-4); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1);
}
.extra-tag-card__title { display: inline-flex; align-items: center; gap: var(--s-2); font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); }
.extra-tag-card__sprue-badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px var(--s-2); border-radius: var(--r-pill); border: var(--bw) solid var(--line-2);
  font-size: var(--fs-12); text-transform: none; letter-spacing: 0; color: var(--text-3);
}
.extra-tag-card__controls { display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap; }
.extra-tag-card__note { font-size: var(--fs-12); color: var(--text-3); }

/* ---------- the progress pool, as drawn sprues ----------
   The body was a column of `.extra-tag-row` divs holding `.extra-tag-slot`
   chips; it is now one or more `skillSprue()` frames (renderProgressSheets in
   ui/team/render.js). Sheets sit side by side and wrap, because a 20-tag pool
   is two frames and the two are peers   the second is not a continuation to
   be read under the first, it is the other sprue in the envelope.

   `.ctd-part-scale` is on the body (set by the renderer) so both frames draw
   at one pixels-per-unit and a tag is the same size on each. --part-px is a
   little under the roster's so a 16-window sheet fits beside its sibling
   without the card scrolling. */
.extra-tag-card__body {
  padding: var(--s-4);
  display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--s-5);
  --part-px: 0.5px;
}
.progress-sheet { flex: 0 1 auto; min-width: 0; max-width: 100%; }

/* The caption for the gesture that has no control (2026-09-12). Adding is a
   press on a named card in the picker; REMOVING is a press on a drawn tag
   window, which carries no X and no minus, so this line is the only thing that
   says it. It sits on the card's own foot rule rather than floating under the
   sheet: at the bottom of the panel it reads as the panel's instruction, and
   the picker starts immediately below it. */
.extra-tag-card__hint {
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-2) var(--s-4) var(--s-3);
  border-top: var(--bw) solid var(--line-1);
  font-size: var(--fs-12); color: var(--text-3);
}
.extra-tag-card__hint .ctd-i { width: 14px; height: 14px; color: var(--accent); }

/* THE OLD CHIP RULES ARE GONE, and this note is what replaced them.
   `.extra-tag-slot` was an HTML `<span>` with a border, a min-width and a
   font-size. It is now a `<rect>` in a generated SVG's hit layer, and every
   one of those properties is inert on a rect   `background` in particular,
   which SVG does not have. Its real look (transparent, pointer-events, the
   hover and focus light) is `.ctd-part-hits > rect` in css/v3/parts.css, one
   STRUCTURAL rule that serves every drawn part rather than one per page that
   happens to make a sheet clickable.

   The class survives because it is the EVENT CONTRACT: both delegated
   handlers in ui/team/index.js select `#extraTagsCards .extra-tag-slot.empty`
   and `.filled`, and `closest()` matches an SVG element like any other. That
   is why the drawn sheet needed no JS change at all. */

.v2-pool-meter { display: flex; flex-direction: column; gap: 4px; }
.v2-pool-meter__bar { height: 6px; border-radius: var(--r-pill); background: var(--bg-alt); overflow: hidden; }
.v2-pool-meter__fill { height: 100%; background: var(--accent); }
.v2-pool-meter__text { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }

.v2-quickfill { display: flex; flex-direction: column; align-items: stretch; gap: var(--s-4); }
.v2-quickfill[hidden] { display: none; }
.v2-quickfill__head { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s-2); }
.v2-quickfill__label { font-size: var(--fs-16); color: var(--text-2); letter-spacing: normal; text-transform: none; }
.v2-quickfill__hint { margin: 0; color: var(--text-2); font-size: var(--fs-14); max-width: var(--measure); }
/* The quick-fill sits under the page's own H2 now, so its label is a section
   heading — display face, a size down from the title. */
.builder-app--v2[data-mwstep="team"] .v2-quickfill__label {
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-20); color: var(--text);
}

/* A SEGMENTED ROW, not a column (declunk 2026-09-08). The four paid-tag
   options used to share the Sprue builder's `.sprue-capacity-selector`, whose
   layout is a column — so Included / +6 / +12 / +18 stacked and the pool
   card's header became 321px tall with the title floating in the middle. */
.v2-tagstep-selector {
  display: inline-flex; flex-wrap: nowrap;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1); overflow: hidden;
}
.v2-tagstep-option {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  min-height: var(--control-h-sm); padding: var(--s-1) var(--s-3);
  border: 0; border-left: var(--bw) solid var(--line-2); border-radius: 0;
  color: var(--text-2); line-height: 1.2;
}
.v2-tagstep-option:first-child { border-left: 0; }
.v2-tagstep-option:hover { background: var(--surface-2); }
.v2-tagstep-option.is-active { background: var(--bone-100); color: var(--ink-950); }
.v2-tagstep-option__count { font-family: var(--font-display); font-weight: 700; font-size: var(--fs-14); font-variant-numeric: tabular-nums; }
.v2-tagstep-option__price { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }
.v2-tagstep-option.is-active .v2-tagstep-option__price { color: var(--ink-950); }

/* ---------- inline progress-skill picker ---------- */
.v2-tagpicker { display: flex; flex-direction: column; gap: var(--s-3); }
.v2-tagpicker:empty { display: none; }
.v2-tagpicker__bar { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.v2-tagpicker__search { flex: 1 1 240px; min-width: 200px; max-width: 420px; display: flex; align-items: center; gap: var(--s-2); }
.v2-tagpicker__search input { flex: 1; min-width: 0; }
.v2-tagpicker__remaining { font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; }
.v2-tagpicker__filters { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.v2-tagpicker__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--s-2); }
.v2-tagpicker__empty { display: flex; align-items: center; justify-content: center; gap: var(--s-2); padding: var(--s-6); color: var(--text-3); grid-column: 1 / -1; }
/* ---------- A SCHOOL IS A PANEL, AND ITS SKILLS RUN DOWN COLUMNS ----------
   Owner, 2026-09-12: "condense them down more, we can organise them much
   better, maybe columns of 4 skills or 3 columns of 6 per column for each
   school."

   What this replaces: six full-width bands, each one header and then its
   skills spraying across the whole width in a `minmax(160px, 1fr)` auto-fill.
   At 2100px that is eight Agility skills on one line and four on the next, a
   header, eight General, four, a header — a shape with no relationship to the
   catalogue it is showing. Nothing lines up between bands, the eye has to
   re-find the left edge on every row, and half the width is spent on cards
   stretched to fill a track.

   Now: the grid lays out SCHOOLS, and each school owns its own little block of
   columns. Two levels, each with one job.

   THE OUTER GRID   auto-fill at 300px, `align-items: start` so a nine-skill
   school does not stretch to a fifteen-skill neighbour's height. Six schools
   land as 6/3/2/1 across as the width comes down, and every panel in a row
   shares a top edge   which is the alignment the bands never had.

   THE INNER COLUMNS are CSS multi-column, not grid, and that is the load-
   bearing choice. `grid-auto-flow: column` needs an explicit row count, and
   the row count that gives "6 per column" is ceil(n / columns)   a different
   number for every school (Agility 12, Devious 9, Mutation 15), so it cannot
   be one static rule. `columns: 136px 3` states the intent directly instead:
   at most three columns, none narrower than 136px, and the browser balances
   the skills between them. A school reads DOWN each column, which is what the
   owner asked for and what an alphabetical list wants. */
/* The two gaps are a hierarchy, not a pair of numbers: 24px BETWEEN schools
   against 8px between a school's own columns, so "the next column" and "the
   next school" are never the same distance. At one shared value the eye read
   Agility's second column as the start of General. */
#extraTagPickerGrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); align-items: start; gap: var(--s-4) var(--s-6); }
/* .skill-category's `grid-column: 1 / -1` is what made every school full
   width. Released here only   the roster's skill modal still wants bands. */
#extraTagPickerGrid .skill-category { grid-column: auto; min-width: 0; }
#extraTagPickerGrid .category-skills {
  display: block; columns: 136px 3; column-gap: var(--s-2);
}
/* Multi-column will slice a box across a column break unless told not to, and
   the margin is on the card because `gap` means nothing inside a multicol. */
#extraTagPickerGrid .skill-card { break-inside: avoid; margin-bottom: 4px; }

/* Condensed further, per the same note: the stepper's padding was still being
   handed back as card height. 28px rows, and the name carries the whole card.
   COARSE POINTERS GET IT BACK   a 28px row is a comfortable mouse target and a
   poor thumb one, so a touch screen keeps 36px. Density is worth having; it is
   not worth a phone user hitting Frenzy when they meant Fend. */
#extraTagPickerGrid .skill-card--tap { padding: 3px var(--s-2); font-size: var(--fs-12); }
@media (pointer: coarse) {
  #extraTagPickerGrid .skill-card--tap { padding: 7px var(--s-2); font-size: var(--fs-13); }
}

/* Below the panel width there is no panel: one school per row, its skills in
   as many columns as the phone can hold. The multicol rule already does that
   on its own — this only stops the outer grid from trying to sit two
   300px-minimum panels in a 375px viewport. */
@media (max-width: 700px) {
  #extraTagPickerGrid { grid-template-columns: 1fr; gap: var(--s-3); }
}

.v2-tagchip {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: var(--control-h-sm); padding: 0 var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-pill);
  color: var(--text-2); font-size: var(--fs-13);
}
.v2-tagchip.is-active { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }

/* THE PICKER BAR STAYS IN REACH while the 72-card grid scrolls under it. */
.v2-tagpicker__bar {
  position: sticky; top: calc(var(--builderbar-h) + var(--s-2)); z-index: 1;
  padding-block: var(--s-2); background: var(--bg);
}

/* TWO COLUMNS ON A WIDE SCREEN (declunk 2026-09-08): the drawn sheets on the
   left, sticky, and the picker on the right, so a pick lands where you are
   looking. The 2026-08-10 "no two columns" call was made when the pool was a
   short chip card that left a dead gap; the sheets grow with the picks and
   fill their column. The card's header stacks by design in the column. */
@media (min-width: 1100px) {
  .builder-app--v2[data-mwstep="tags"] .fill-sprue-section {
    display: grid; grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
    grid-template-areas: "head head" "cards picker";
    column-gap: var(--s-6); row-gap: var(--s-4); align-items: start;
  }
  .builder-app--v2[data-mwstep="tags"] .fill-sprue-section__head { grid-area: head; }
  .builder-app--v2[data-mwstep="tags"] .extra-tags-cards {
    grid-area: cards; position: sticky; top: calc(var(--builderbar-h) + var(--s-3));
    max-height: calc(100vh - var(--builderbar-h) - var(--actionbar-h) - var(--s-6)); /* lint-allow: vh — sticky column cap, the page still scrolls */
    overflow-y: auto;
  }
  .builder-app--v2[data-mwstep="tags"] .v2-tagpicker { grid-area: picker; }
  .builder-app--v2[data-mwstep="tags"] .extra-tag-card__header { flex-direction: column; align-items: stretch; }
  .builder-app--v2[data-mwstep="tags"] .extra-tag-card__body { --part-px: 0.62px; justify-content: center; }
}

/* ============================================================
   9 · SKILL CARDS + PICKERS
   ============================================================ */
.available-skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--s-2); }
.skill-category { grid-column: 1 / -1; display: flex; flex-direction: column; gap: var(--s-2); }
.category-skills { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--s-2); }
/* Category hue reaches more than the dot, so body.cb-mode does real work: a
   left edge on the card and the header underline. Never the only carrier — the
   dot and the category name are always present (DESIGN-SYSTEM.md §7). */
.skill-card[class*="cat-"] { border-left: 3px solid var(--cat, var(--line-1)); }
.category-header[class*="cat-"] { border-bottom-color: var(--cat, var(--line-1)); }
.v2-tagchip[class*="cat-"] .ctd-chip__dot, .filter-btn[class*="cat-"] .ctd-chip__dot { background: var(--cat, var(--bone-500)); }
.rpc-skill-slot[class*="cat-"].base { border-left: 3px solid var(--cat, var(--line-2)); }
.category-header {
  display: flex; align-items: center; gap: var(--s-2);
  padding-bottom: 4px; border-bottom: var(--bw) solid var(--line-1);
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-caps); font-size: var(--fs-13); color: var(--text-2);
}
.skill-category.is-primary .category-header::after,
.skill-category.is-secondary .category-header::after {
  margin-left: auto; font-family: var(--font-body); text-transform: none;
  letter-spacing: 0; font-size: var(--fs-12); color: var(--text-3);
}
.skill-category.is-primary .category-header::after { content: 'Primary access'; color: var(--ok); }
.skill-category.is-secondary .category-header::after { content: 'Secondary access'; }

.builder-card {
  background: var(--surface); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-1); color: var(--text);
}
.skill-card {
  position: relative;
  display: flex; flex-direction: column; gap: var(--s-2);
  padding: var(--s-2) var(--s-3); min-height: var(--control-h);
  text-align: left; font-size: var(--fs-13);
  transition: border-color var(--t-fast) var(--ease);
}
.skill-card:hover { border-color: var(--line-2); }
.skill-card.selected, .skill-card.picked { border-color: var(--accent); background: var(--surface-2); }
.skill-card.has-qty { border-color: var(--accent); }
.skill-card.is-disabled, .skill-card:disabled { opacity: 0.45; pointer-events: none; }
.skill-card.is-filtered-out { display: none; }
.card-name { font-weight: 600; line-height: var(--lh-snug); }
.card-info-btn { position: absolute; top: 4px; right: 4px; color: var(--text-3); }
.card-info-btn:hover { color: var(--text); }
.skill-card__qty-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); margin-top: auto; }

/* ---------- the star picker: a tray of real tags ----------
   Owner, 2026-09-16: "make them look more like the skills on the actual skill
   tag. and condense them down and the same size."

   SCOPED TO #starSkillsGrid because .skill-card is shared with the roster and
   progress pickers, which are lists of names and want to stay that way.

   THE CARD IS A HIT AREA AND NOTHING ELSE. The tag inside it is a drawing of
   the moulding (js/ctd-parts.js tag()) and carries its own face, bevel, wear
   and category tint, so a border or a fill behind it would be a second object
   around the first. Selection is the one thing the drawing cannot say — the
   customer's own choice is not a property of the part — so that is the only
   state the card paints. */
/* SIX TO A ROW, fixed (owner, 2026-09-16: "Make it so its rows of 6"). Not
   auto-fill: the count is the point, so the tags stay the same size as each
   other whatever the dialog is doing, and the eye can count a row. The dialog
   is --wide and the tray is the only thing in it, so six lands at ~130px a tag.
   Below 620px six of them would be 45px each, which is smaller than the tag is
   legible at, so a phone drops to three. */
#starSkillsGrid .category-skills {
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: var(--s-2);
}
@media (max-width: 620px) {
  #starSkillsGrid .category-skills { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
#starSkillsGrid .skill-card--tag {
  display: flex; align-items: center; gap: 0;
  padding: 3px; min-height: 0; min-width: 0;
  background: transparent; border: var(--bw) solid transparent;
  border-radius: var(--r-1);
  transition: border-color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
#starSkillsGrid .skill-card--tag .ctd-part--tag { display: block; width: 100%; height: auto; }
#starSkillsGrid .skill-card--tag:hover,
#starSkillsGrid .skill-card--tag:focus-visible { background: var(--bg-alt); border-color: var(--line-2); }
#starSkillsGrid .skill-card--tag.selected { border-color: var(--accent); background: var(--surface-2); }
/* The check is BUILT ON EVERY CARD and shown by the class, because selection is
   now a class toggle on a node that already exists — adding and removing the
   element would mean touching the DOM inside a card whose 4KB of drawing we are
   deliberately not re-rendering. */
#starSkillsGrid .skill-card--tag .selected-check { display: none; }
#starSkillsGrid .skill-card--tag.selected .selected-check {
  display: block; position: absolute; top: 2px; right: 2px; color: var(--accent);
}
#starSkillsGrid .skill-card--tag[hidden] { display: none; }
#starSkillsGrid .skill-category[hidden] { display: none; }

/* ---------- the progress picker's card: one row, one press ----------
   The `+`/`-` stepper went on 2026-09-12 (see renderAddTagCard in
   ui/team/render.js), and what is left is a name and, once it is on the sheet,
   a tally. So the card stops being a two-row block with a control strip and
   becomes a chip the height of one line: `flex-direction: row` over the
   column `.skill-card` sets, and `min-height` released from --control-h,
   which was sized for the buttons that are no longer in here.

   The name TRUNCATES rather than wraps. Every other option costs more than it
   is worth at this card count: wrapping makes the rows in a grid row unequal,
   and sizing the column to the longest skill name ("Strip Ball", "Break
   Tackle", "Sneaky Git") would set every column by the worst case. The full
   name is on the element's `title` and in its `aria-label`. */
.skill-card--tap {
  flex-direction: row; align-items: center; gap: var(--s-2);
  min-height: 0; padding: 6px var(--s-2) 6px var(--s-3);
  cursor: pointer;
}
.skill-card--tap .card-name {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A TALLY, NOT A FIELD. It only exists above zero, it is aria-hidden (the
   card's own label carries the count in words), and the multiplication sign is
   doing real work: a bare "2" in the corner of a pressable card reads as a
   number you are meant to edit. */
.skill-card__count {
  flex: none;
  padding: 1px 6px; border-radius: var(--r-pill);
  background: var(--accent); color: var(--bg);
  font-size: var(--fs-12); font-weight: 700; line-height: 1.5;
  font-variant-numeric: tabular-nums;
}
.selected-check { color: var(--accent); }


.qty-btn {
  width: 28px; height: 28px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  color: var(--text); font-size: var(--fs-16); line-height: 1;
}
.qty-btn:hover:not(:disabled) { border-color: var(--bone-500); }
.qty-btn:disabled { opacity: 0.35; }
.qty-value { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 2ch; text-align: center; }

.skill-filter-bar { display: flex; gap: var(--s-2); flex-wrap: wrap; }
.filter-btn {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: var(--control-h-sm); padding: 0 var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-pill);
  color: var(--text-2); font-size: var(--fs-13);
}
.filter-btn:hover { border-color: var(--bone-500); }
.filter-btn.active { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }
.filter-text { white-space: nowrap; }

.skill-search-box { position: relative; display: flex; align-items: center; gap: var(--s-2); }
.skill-search-box > .ctd-i { position: absolute; left: var(--s-3); color: var(--text-3); pointer-events: none; }
.skill-search-box input { flex: 1; padding-left: var(--s-10); }
.clear-search { position: absolute; right: var(--s-3); color: var(--text-3); }

.skill-modal-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); flex-wrap: wrap; }
.skill-modal-print-hint { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-12); color: var(--text-3); }
.skill-count-indicator { display: inline-flex; align-items: baseline; gap: 2px; margin-right: auto; font-variant-numeric: tabular-nums; color: var(--text-2); }
.count-current { font-family: var(--font-display); font-weight: 700; font-size: var(--fs-18); color: var(--text); }
.count-separator, .count-max { color: var(--text-3); }
.base-size-badge, .modal-header-badge {
  display: inline-block; padding: 2px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); font-size: var(--fs-12);
  text-transform: none; letter-spacing: 0; color: var(--text-3);
}

/* ============================================================
   10 · SPRUE BUILDER
   ============================================================ */
.sprue-workbench { display: flex; flex-direction: column; gap: var(--s-6); }
.sprues-container { display: flex; flex-direction: column; gap: var(--s-6); }
.sprues-empty { padding: var(--s-12); text-align: center; color: var(--text-3); display: flex; flex-direction: column; align-items: center; gap: var(--s-3); }
.sprues-empty[hidden] { display: none; }

.sprue-card { background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-2); overflow: hidden; }
.sprue-card-header, .sprue-card-head {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); flex-wrap: wrap;
  padding: var(--s-3) var(--s-4); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1);
}
.sprue-card-title-group { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sprue-card-title { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-18); }
.sprue-card-subtitle { font-size: var(--fs-12); color: var(--text-3); }
.sprue-card-crest { width: 32px; height: 32px; flex: none; }
.sprue-card-tier { font-size: var(--fs-13); color: var(--gold-300); font-variant-numeric: tabular-nums; }
.sprue-card-tools { display: flex; align-items: center; gap: var(--s-2); }
.sprue-card-body { padding: var(--s-4); display: flex; flex-direction: column; gap: var(--s-4); }
.sprue-card-footer { padding: var(--s-3) var(--s-4); border-top: var(--bw) solid var(--line-1); display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); flex-wrap: wrap; }
.sprue-card-actions { display: flex; align-items: center; gap: var(--s-2); }
.sprue-card-summary { font-size: var(--fs-13); color: var(--text-2); }
.sprue-summary-pill {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 2px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); font-size: var(--fs-12); color: var(--text-3);
}

.sprue-capacity-selector { display: flex; flex-direction: column; gap: var(--s-2); }
.sprue-capacity-field { display: flex; flex-direction: column; gap: 4px; }
.sprue-capacity-field__label { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.sprue-capacity-option {
  display: flex; flex-direction: column; gap: 2px; align-items: center;
  min-width: 72px; padding: var(--s-2) var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1); color: var(--text-2);
}
.sprue-capacity-option.is-active { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }
.sprue-capacity-option__count { font-family: var(--font-display); font-weight: 700; font-size: var(--fs-18); font-variant-numeric: tabular-nums; }
.sprue-capacity-option__price { font-size: var(--fs-12); font-variant-numeric: tabular-nums; }

/* ---- the printed frame preview ----
   IT IS THE DRAWN SPRUE NOW (js/ctd-parts.js skillSprue, placed by
   ui/sprue/render.js), so almost everything that used to be here is gone:
   .sprue-frame__columns / __column / __column-label / __stack and the whole
   .sprue-slot run were a CSS picture of a sprue — dashed boxes in columns —
   and the real one arrives as SVG carrying its own material. What is left is
   the panel round it and the one number that sizes it.

   THE PARTS ARE SIZED BY --part-px, NOT BY A WIDTH. Every asset publishes its
   natural size as --pw and .ctd-part-scale (css/v3/parts.css) turns that into
   width: calc(--pw * --part-px). Setting one number here is what keeps a tag
   the same size on a 6-tag sheet and on a 16-tag one — a width would make the
   narrow sheet's tags BIGGER, which is the one thing a set of identical parts
   must never do. The steps below are the same argument at each breakpoint. */
.sprue-frame-panel { border: var(--bw) solid var(--line-2); border-radius: var(--r-2); overflow: hidden; }
.sprue-frame-panel__head { padding: var(--s-2) var(--s-3); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1); display: flex; flex-direction: column; gap: 2px; }
.sprue-frame-panel__eyebrow { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.sprue-frame-panel__title { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.sprue-frame-panel__hint { font-size: var(--fs-12); color: var(--text-3); }

/* THE SHEETS SIT SIDE BY SIDE AND WRAP, because that is how two of them come
   out of the envelope. A 24-tag order is a 16 and an 8 (planSkillSheets), and
   the 8 is genuinely shorter — aligning them at the TOP is what says they are
   two frames of one moulding rather than one ragged picture. */
.sprue-frame { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: center;
  gap: var(--s-4); padding: var(--s-4) var(--s-3); --part-px: 0.62px; }
.sprue-sheet { flex: 0 0 auto; max-width: 100%; }


/* NO BREAKPOINT STEPS, AND THAT IS MEASURED RATHER THAN LAZY. Every skill
   sheet is 514 units WIDE whatever it holds — two columns is the constant and
   only the row count varies — so one sheet is 319px at this scale, which fits
   a 390px phone's card with room to spare and two of them fit the desktop
   panel side by side. A ladder of smaller values on narrow screens (which is
   what this block used to be) shrank a sheet that already fitted, and took
   the printed codes down with it. Where a sheet genuinely cannot fit,
   .ctd-part-scale's own max-width:100% shrinks it and nothing else has to
   know. */

.sprue-preset-tray { border: var(--bw) solid var(--line-1); border-radius: var(--r-2); overflow: hidden; }
.sprue-preset-tray__head { padding: var(--s-2) var(--s-3); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1); display: flex; flex-direction: column; gap: 2px; }
.sprue-preset-tray__eyebrow { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.sprue-preset-tray__title { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.sprue-preset-tray__hint { font-size: var(--fs-12); color: var(--text-3); }
.sprue-preset-toggle { color: var(--link); font-size: var(--fs-13); }
.sprue-preset-group { padding: var(--s-3); display: flex; flex-direction: column; gap: var(--s-2); }
.sprue-preset-group__label { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.sprue-preset-buttons { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--s-2); }
.sprue-preset-button {
  display: flex; align-items: center; gap: var(--s-2); text-align: left;
  padding: var(--s-2) var(--s-3);
  background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-1);
  color: var(--text);
}
.sprue-preset-button:hover { border-color: var(--line-2); }
.sprue-preset-button__icon { color: var(--accent-2); flex: none; }
.sprue-preset-button__content { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.sprue-preset-button__title { font-size: var(--fs-14); font-weight: 600; }
.sprue-preset-button__meta { font-size: var(--fs-12); color: var(--text-3); }

.sprue-skill-catalogue { border: var(--bw) solid var(--line-1); border-radius: var(--r-2); overflow: hidden; }
.sprue-skill-catalogue__head { padding: var(--s-2) var(--s-3); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1); display: flex; flex-direction: column; gap: 2px; }
.sprue-skill-catalogue__eyebrow { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.sprue-skill-catalogue__title { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.sprue-skill-catalogue__hint { font-size: var(--fs-12); color: var(--text-3); }
.sprue-skill-filter-bar { display: flex; gap: var(--s-2); flex-wrap: wrap; padding: var(--s-3); border-bottom: var(--bw) solid var(--line-1); }
.sprue-filter-btn__label { white-space: nowrap; }
.sprue-filter-btn__count { font-variant-numeric: tabular-nums; color: var(--text-3); }
.sprue-skills-scroll { max-height: 420px; overflow-y: auto; padding: var(--s-3); }
.sprue-skill-groups { display: flex; flex-direction: column; gap: var(--s-4); }
.sprue-skill-category { display: flex; flex-direction: column; gap: var(--s-2); }
.sprue-skill-category__label { display: flex; align-items: center; gap: var(--s-2); font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-caps); font-size: var(--fs-13); color: var(--text-2); }
.sprue-skill-category__meta { margin-left: auto; font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }
.sprue-skill-empty { padding: var(--s-6); text-align: center; color: var(--text-3); }
/* ui/sprue/render.js filters by adding the class `hidden` (not the attribute).
   Without this rule the category filter looked like it worked — the button lit
   up — while all 108 skill cards stayed on screen. */
.sprue-skill-category.hidden { display: none; }
/* `.v2-sprue-tray` IS GONE, and it is a deletion rather than a move. It set
   `flex-direction: column` on the progress card's body   the same element
   `.extra-tag-card__body` styles   and it was declared 340 lines later, so it
   silently won. That was harmless while the body was a stack of tag ROWS and
   fatal the moment the body became a row of drawn SHEETS: two 257px frames in
   a 1358px card stacked vertically, and nothing in the rule that set the
   direction mentioned sprues at all.
   One element should not carry two classes that argue about its layout. The
   class had exactly one emitter (ui/team/render.js) and no JS query, so it
   left the markup in the same commit. The legacy .v2-sprue-tray rules in
   css/team-builder.css belong to the v1 tree, which does not load here. */

/* tier progress meter */
.tier-progress-bar { display: flex; flex-direction: column; gap: var(--s-2); }
.tier-progress-mini .progress-track { height: 4px; }
.tier-badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); font-size: var(--fs-12); color: var(--text-3);
}
.progress { display: flex; flex-direction: column; gap: 4px; }
.progress-track { height: 6px; border-radius: var(--r-pill); background: var(--bg-alt); position: relative; }
.progress-fill { height: 100%; border-radius: var(--r-pill); background: var(--accent); }
.progress-info { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); font-size: var(--fs-12); color: var(--text-3); padding-top: var(--s-4); }
.progress-count { font-variant-numeric: tabular-nums; }
.progress-checkpoint {
  position: absolute; top: 50%; transform: translate(-50%, -50%);
  display: flex; flex-direction: column; align-items: center; gap: var(--s-1);
}
.checkpoint-dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--line-2); border: var(--bw) solid var(--bg-alt); }
.checkpoint-label { position: absolute; top: 12px; font-size: var(--fs-12); color: var(--text-3); }
/* A checkpoint the sprue has already passed, and the tier it is sitting on. */
.progress-checkpoint.reached .checkpoint-dot { background: var(--accent); }
.progress-checkpoint.reached .checkpoint-label { color: var(--text-2); }
.progress-checkpoint.active .checkpoint-dot { background: var(--bone-100); }
.progress-checkpoint.active .checkpoint-label { color: var(--text); font-weight: 700; }

/* ------------------------------------------------------------
   10b · THE LETTERING PICKER

   Owner 2026-09-12: "add the font selector to the sprue builder, when the font
   is changed, all the font on the skills should change as well dynamically."

   The production generator cuts a part in one of ten themed display faces, so
   unlike the paint planner next door this is previewing something real: the
   .ttf a chip is set in IS the file the letters are cut from. The panel sits
   above the sprue cards rather than inside one so the frames redraw IN VIEW
   while a face is being chosen, and so it survives the wholesale re-render
   of #spruesContainer. Behaviour, copy and the catalogue itself are in
   js/builder-v2/ui/print-font.js; nothing in here names a typeface.
   ------------------------------------------------------------ */
.sprue-lettering { display: flex; justify-content: flex-end; margin-bottom: var(--s-3); }
.sprue-lettering__toggle { display: inline-flex; align-items: center; gap: var(--s-2); }
.sprue-lettering__label {
  font-family: var(--font-display); text-transform: uppercase;
  letter-spacing: var(--track-caps); font-size: var(--fs-12); color: var(--text-3);
}
/* The face's NAME is the state, so it carries the weight and the button reads
   as a value rather than as a verb: "Lettering  Graveborn", not "Lettering". */
.sprue-lettering__current { font-weight: 700; color: var(--text-1); }
.sprue-lettering__toggle.is-active { border-color: var(--accent); }
/* A THEMED FACE IS ON, AND THE BUTTON HAS TO SAY SO WITH THE PANEL SHUT —
   the same argument as body.ctd-painted above the roster. `.is-active` means
   the panel is OPEN, which is a different fact and does not survive a reload;
   the choice does (localStorage, ui/print-font.js). Without this, somebody who
   picked Rotblood last week opens the builder to dripping tags and nothing on
   screen explaining them. */
body.ctd-print-themed .sprue-lettering__current { color: var(--accent); }

.print-font-panel {
  display: flex; flex-direction: column; gap: var(--s-3);
  padding: var(--s-4); margin-bottom: var(--s-4);
  background: var(--surface);
  border: var(--bw) solid var(--line-1); border-radius: var(--r-2);
}
.print-font-panel[hidden] { display: none; }
.print-font-panel__head { display: flex; align-items: center; gap: var(--s-2); }
.print-font-panel__title {
  display: flex; align-items: center; gap: var(--s-2); margin: 0;
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14); color: var(--text-1);
}
.print-font-panel__close { margin-left: auto; }
/* Same treatment as the paint planner's disclaimer, for the same reason: the
   sentence about what actually ships is the one thing here that must not be
   skipped, so it gets a rule in the accent and body-size text rather than
   fine print. */
.print-font-panel__note {
  margin: 0; padding: var(--s-2) var(--s-3);
  border-left: 3px solid var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  font-size: var(--fs-13); line-height: 1.5; color: var(--text-2);
}
.print-font-panel__note strong { color: var(--text-1); }

/* auto-fill and not a breakpoint: the chips are specimens and their useful
   width is set by the longest name in the set, not by the viewport. */
.print-font-grid {
  display: grid; gap: var(--s-2);
  grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
}
.print-font-chip {
  display: flex; flex-direction: column; gap: 2px; text-align: left;
  padding: var(--s-2) var(--s-3); cursor: pointer;
  background: var(--bg-alt); border: var(--bw) solid var(--line-2);
  border-radius: var(--r-1); color: var(--text-2); font: inherit;
  transition: border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.print-font-chip:hover { color: var(--text-1); border-color: var(--text-3); }
.print-font-chip.is-active { color: var(--accent); border-color: var(--accent); }
/* THE SPECIMEN IS SET IN THE FACE IT NAMES. --font-specimen is written inline
   per chip by ui/print-font.js, so the ten stacks live in the module that owns
   the catalogue instead of being copied into this stylesheet where they would
   drift from it. The fallback is the Varsity stack, which is what a chip shows
   for the half-second before its .ttf lands. */
.print-font-chip__specimen {
  font-family: var(--font-specimen, var(--font-varsity));
  font-size: var(--fs-24); line-height: 1.15; color: var(--text-1);
}
.print-font-chip.is-active .print-font-chip__specimen { color: var(--accent); }
/* Three real skill codes, in caps, at something near the proportions a tag
   prints them at — a name alone does not show what a face does to BLOCK. */
.print-font-chip__sample {
  font-family: var(--font-specimen, var(--font-varsity));
  font-size: var(--fs-13); letter-spacing: var(--track-caps); color: var(--text-3);
}
.print-font-chip__note { font-size: var(--fs-12); line-height: 1.4; color: var(--text-3); }
/* Classic Varsity is the PRODUCT, not one option among ten, so its chip reads
   as the baseline: it is also the only one that clears the override instead of
   setting one. */
.print-font-chip--ship { border-color: var(--line-1); background: var(--surface-2); }

/* ============================================================
   11 · STAR PLAYER
   ============================================================ */
/* HOW THE FRAME WORKS, ONCE, ABOVE THE CARDS (owner, 2026-09-16: "we need a
   bit of a guide at the top on how it works"). It sat on every card until
   2026-09-15 and was four lines of the same sentence repeated down the page;
   one strip says it for all of them. Four steps because there are four kinds of
   feature, and each names the PART rather than the field — "the stub", "the
   plate" — since the part is what the customer is looking at. */
.spc-guide {
  display: grid; gap: var(--s-2) var(--s-4);
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  padding: var(--s-3) var(--s-4);
  background: var(--bg-alt); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-1);
}
.spc-guide__step { display: flex; align-items: flex-start; gap: var(--s-2); font-size: var(--fs-13); color: var(--text-2); }
.spc-guide__step .ctd-i { color: var(--accent); flex: none; margin-top: 2px; }
.spc-guide__step b { color: var(--text); font-weight: 600; }

.star-add-strip { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s-3); }
.star-add-card {
  display: flex; align-items: center; gap: var(--s-3); text-align: left;
  padding: var(--s-4);
  background: var(--surface); border: var(--bw) solid var(--line-1); border-radius: var(--r-2);
  color: var(--text); min-height: var(--control-h-lg);
  transition: border-color var(--t-fast) var(--ease);
}
.star-add-card:hover { border-color: var(--accent); }
.star-add-card--40 { border-style: dashed; }
.star-add-card__icon { color: var(--accent-2); flex: none; }
.star-add-card__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.star-add-card__title { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.star-add-card__hint { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }
.star-add-card__plus { margin-left: auto; color: var(--accent); }

.star-players-header[hidden] { display: none; }

/* ============================================================
   THE CARD IS A TILE — the star builder, 2026-09-15.

   Owner: "currently it is massive, it should be the sprue, next to a bunch of
   controls ... make it a manageable card, as we can add multiple, try 2 in a
   row, or 3 in a row."

   IT WAS MASSIVE FOR ONE REASON AND IT IS WORTH WRITING DOWN. .spc-sprue had a
   min-width of 520px and no maximum. The floor was right and deliberate — a
   767-unit drawing fitted to a 360px phone puts a tag row at 28px with 39px
   between row centres, so no field on it can be made pressable — but the
   CEILING was never set, so `max-width: 100%` let the same drawing render at
   1166px on a 1200px stage. One card came out 1200x930 with a 325x91 skill
   window, and two stars were nearly two thousand pixels of scrolling.

   Capping it is most of the fix. The rest is that the frame can no longer BE
   the form at tile size (24px tag rows), so the controls moved off it into a
   column beside it — see ui/star/render.js. The frame keeps a clickable region
   per feature that focuses that feature's control, so pointing at the part you
   want still works, which was the good half of 2026-09-07.
   ============================================================ */

/* TWO TO A ROW, AND THAT IS WHAT THE WRAP AFFORDS. --wrap is 1200px and it is
   not this stylesheet's to move: the girders, the bay gutters and the nav are
   all measured off it (tokens.css). Two tracks put a card at 592px — a ~300px
   frame you can read beside a control column that is not cramped. Three tracks
   would be 389px each, which is a 190px frame and a 190px column: the drawing
   stops being a proof and the stat line stops fitting on one row. So the third
   column is what the CARD does when it has the room, not what the grid does. */
.star-player-cards { display: grid; gap: var(--s-4); grid-template-columns: 1fr; }
@media (min-width: 1000px) {
  .star-player-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.star-player-card {
  background: var(--surface); border: var(--bw) solid var(--line-1);
  border-radius: var(--r-2); overflow: hidden;
  display: flex; flex-direction: column; min-width: 0;
}
.star-player-card:focus-within { border-color: var(--accent); }

.spc-head {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--bw) solid var(--line-1); background: var(--bg-alt);
}
.spc-head__kind {
  display: inline-flex; align-items: center; gap: var(--s-2); min-width: 0;
  white-space: nowrap;
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14); color: var(--text);
}
/* FOUR THINGS ON ONE BAR NEEDS ABOUT 420px, and a phone gives the card 327, so
   the bar is allowed to wrap there. It is ONLY the wrap: an `order` and a
   `flex: 1 1 100%` sat here too, to push the lettering onto a row of its own,
   and they never applied — .spc-letterpick's own `flex: 0 1 auto` is further
   down this file at the same specificity and won. Removed rather than moved,
   because the natural wrap is the better behaviour: at 573px the four fit on
   one line and forcing a second row would spend height on nothing. */
@media (max-width: 620px) {
  .spc-head { flex-wrap: wrap; }
}
.spc-head__kind .ctd-i { color: var(--accent-2); flex: none; }
/* THE FACE, IN THE CARD'S BAR (owner, 2026-09-15: "the lettering should be in
   the top bar, as a dropdown or something"). The closed control is lettered in
   the face it names — --font-specimen is written inline per card by
   ui/star/render.js, so the ten stacks stay in the module that owns the
   catalogue — because a dropdown reading "Verminscratch" in Barlow says nothing
   about what the tags will look like. Each <option> carries its own family too,
   which some browsers honour in the popup list and none break on. */
/* IT EXPANDS IN THE CARD RATHER THAN OPENING A PLATFORM MENU (owner,
   2026-09-16: "the dropdown makes it hard to see the text, could we have it
   expand down"). A native <select> renders its list in the OS's own chrome at
   the OS's own size, where half of these faces are illegible and a browser may
   ignore the per-option family altogether — and the closed control was itself
   set in whatever decorative face was chosen. The panel is ours, so each row
   can be a real specimen at a size worth reading. */
.spc-letterpick { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; }
.spc-letterpick__toggle {
  display: inline-flex; align-items: center; gap: 4px; min-width: 0;
  min-height: var(--control-h-sm); padding: 0 var(--s-1);
  background: transparent; border: var(--bw) solid transparent;
  border-radius: var(--r-1); color: var(--text-2); font-size: var(--fs-13);
  transition: border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.spc-letterpick__toggle .ctd-i { color: var(--text-3); flex: none; }
.spc-letterpick__toggle:hover { border-color: var(--line-2); color: var(--text); }
.spc-letterpick__toggle:focus-visible { border-color: var(--focus); color: var(--text); outline: none; }
/* THE TOGGLE'S NAME IS A SPECIMEN, the panel's rows are the rest of them. */
.spc-letterpick__name {
  font-family: var(--font-specimen, var(--font-varsity)), var(--font-display);
  font-size: var(--fs-14); color: var(--text);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Over the frame rather than pushing it down — a list that reflowed the card
   would move the drawing the customer is comparing against. */
.spc-fontlist {
  /* --z-sticky and not a number of its own: the panel has to clear the card
     beside it and the builder's own sticky furniture, and nothing in this app
     stacks between sticky and dialog. There is no --z-pop and inventing one for
     a single popover would be a token the design system has to carry forever. */
  position: absolute; top: 100%; left: 0; z-index: var(--z-sticky);
  /* Wide enough for the widest NAME set in its own face plus a code beside it.
     Ironjaw and Rot Blood are the two that decide it — at 220px their samples
     clipped to "BLOCK · DOD". */
  width: max(300px, 100%); max-height: 320px; overflow-y: auto;
  margin-top: 4px; padding: 4px;
  display: flex; flex-direction: column; gap: 2px;
  background: var(--surface-2); border: var(--bw) solid var(--line-2);
  border-radius: var(--r-1); box-shadow: var(--shadow-2);
}
.spc-fontlist[hidden] { display: none; }
.spc-fontrow {
  display: flex; align-items: baseline; gap: var(--s-2);
  padding: var(--s-1) var(--s-2); width: 100%; text-align: left;
  background: transparent; border: var(--bw) solid transparent;
  border-radius: var(--r-1); color: var(--text-2);
  transition: border-color var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.spc-fontrow:hover, .spc-fontrow:focus-visible { background: var(--bg-alt); border-color: var(--line-2); }
.spc-fontrow.is-active { border-color: var(--accent); color: var(--text); }
.spc-fontrow__name {
  font-family: var(--font-specimen, var(--font-varsity)), var(--font-display);
  font-size: var(--fs-18); color: var(--text); line-height: 1.2;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Two real codes in caps, because a NAME set in a face does not show what that
   face does to BLOCK — which is the job these faces actually do. */
.spc-fontrow__sample {
  margin-left: auto; flex: none;
  font-family: var(--font-specimen, var(--font-varsity)), var(--font-display);
  font-size: var(--fs-12); letter-spacing: var(--track-caps); color: var(--text-3);
}

.spc-head__tags {
  font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.spc-head__price {
  margin-left: auto;
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-16);
  color: var(--gold-300); font-variant-numeric: tabular-nums;
}
.spc-head__remove { color: var(--text-3); flex: none; }
.spc-head__remove:hover { color: var(--err); }

/* THE CARD IS THE FRAME (owner, 2026-09-16: "the skill tag bit doesnt need to
   be there, they can select them on the sprue itself. make the sprue a bit
   bigger to accomodate this").

   THE SECOND COLUMN IS WHAT PAID FOR THE SIZE. It held the drawing to 340px,
   where a skill window is 45x26 — too small to be the control, which is exactly
   why the column existed to list the tags instead. With the card's whole width
   the frame is ~577px and every feature is big enough to be pressed: a 161x45
   window, a 61x61 compartment, a 54x45 stub, a 239x71 marker word. Nothing has
   to be listed twice, so nothing is. */
.spc-build { padding: var(--s-3); min-width: 0; }

/* A PHONE STILL NEEDS THE FLOOR AND THE SCROLLER, and this is the one place the
   2026-09-16 shape cannot hold on its own. A 327px card gives the frame 301px,
   where a skill window is 76x21 — and a window is now the ONLY way to pick a
   skill, so leaving it at 21px puts the feature behind a steady hand. 520px is
   where a tag row reaches 41px with bare runner either side of it, which is the
   same number the old full-width card used and for the same reason.

   It is scoped to narrow screens and nothing else. Above 620px the card is wide
   enough to give the frame 545px+ on its own, so no minimum applies and no
   scrollbar appears — the desktop card the owner asked for is untouched. */
@media (max-width: 620px) {
  .spc-build {
    overflow-x: auto;
    overflow-y: hidden;
    overscroll-behavior-x: contain;
    scrollbar-width: thin;
  }
  .spc-build > .spc-sprue { min-width: 520px; }
}

/* Exactly the drawing's box, because every region inside is positioned as a
   percentage of the viewBox — see mountSprueMap. NO min-width and no scroller
   any more: nothing on the frame has to be big enough to press, so it is free
   to be as small as its tile. */
/* A QUERY CONTAINER, so the fields standing on the frame can be sized in the
   FRAME's units rather than the page's. Everything in the layer is positioned
   as a percentage of the viewBox and scales perfectly; the type inside did not,
   because a font-size in px knows nothing about how big the drawing came out.
   At 575px a 14px field was a fifth the height of the compartment it sat in —
   owner, 2026-09-16: "the text is tiny when inputting". In cqw it tracks the
   part at every width, which is the same contract the geometry already has. */
.spc-sprue { position: relative; min-width: 0; max-width: 100%; container-type: inline-size; }
.spc-sprue > svg { display: block; width: 100%; height: auto; }

/* The rects the generator laid over the features are GEOMETRY, not controls.
   The region mounted on top of each one is what gets clicked; leaving the rect
   hittable would mean two overlapping targets and the lower one does nothing. */
.spc-hit { pointer-events: none; }

/* `pointer-events: none` on the layer and `auto` on the regions keeps the bare
   runner between two tags un-clickable, which is true of the object: the frame
   is not a control, its features are. */
.spc-fields { position: absolute; inset: 0; pointer-events: none; }
.spc-sprue.is-locked .spc-fields { display: none; }

/* ---- a skill window ----
   Every region left on the frame is one: the other features are real fields
   standing on their own part, so a window is the only thing that needs pointing
   at, and since 2026-09-16 it is the ONLY way to pick a skill.

   AN EMPTY WINDOW HAS TO SAY SO. "The moulding says what everything is" holds
   for a FILLED frame and says nothing on a blank one — an empty window is dark
   plastic, indistinguishable from the runner around it. So it is ringed in the
   accent that means "you can act here" everywhere else in the builder, and
   carries a plus, which is the one thing a rectangle cannot say for itself:
   press here for a list of two hundred skills.

   IT IS A HAIRLINE, NOT THE FOCUS RING. Half the alpha and a third of the width
   reads as "available" where the focus ring reads as "here" — five accent boxes
   at full weight would be a form drawn on top of the part. */
.spc-hot {
  position: absolute;
  left: var(--x); top: var(--y); width: var(--w); height: var(--h);
  margin: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
  pointer-events: auto;
  appearance: none; background: transparent; border: 0;
  border-radius: var(--r-1);
  cursor: pointer;
  box-shadow: inset 0 0 0 var(--bw) color-mix(in srgb, var(--accent) 52%, transparent);
  transition: box-shadow var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
/* A filled window already reads correctly — it has a tag cast in it — so it
   drops the ring and keeps its mark faint. That mark is the only thing saying a
   printed tag can be CHANGED, and the customer's third pick is exactly the one
   they change. */
.spc-hot.is-filled { box-shadow: none; }
.spc-hot:hover, .spc-hot:focus-visible {
  box-shadow: inset 0 0 0 2px var(--accent);
  background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.spc-hot__mark {
  display: flex; align-items: center; justify-content: center;
  color: var(--accent); opacity: 0.85;
  transition: opacity var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
  pointer-events: none;
}
.spc-hot.is-filled .spc-hot__mark { opacity: 0; color: var(--bone-100); }
.spc-hot:hover .spc-hot__mark, .spc-hot:focus-visible .spc-hot__mark { opacity: 1; }

/* ---- one field, four shapes ----
   Owner, 2026-09-15: "they should input the stats directly onto the sprue",
   then "we dont need the number or nameplate, they can enter them themselves."
   So every value a customer TYPES stands on the part that carries it — the
   number stub, the nameplate, the five stat compartments, the two marker words.
   The skill windows are the exception: a window is 26px tall at tile size and
   what goes in it is one of two hundred skills chosen by a name the window does
   not print, so its rect gets a .spc-hot that opens the picker in the column.

   THE VALUE IS NOT DRAWN BY THE FIELD. It is moulded on the compartment
   underneath, which is the point of typing here at all, so the control is
   transparent until it has focus and then becomes an opaque plate carrying what
   you are typing. Two states, and the second exists because a caret over cast
   lettering is unreadable. The face is the tags' face: --font-print is set per
   card on .spc-sprue, so what you type looks like what will be cut. */
.spc-f {
  position: absolute;
  left: var(--x); top: var(--y); width: var(--w); height: var(--h);
  margin: 0; padding: 0 2px;
  pointer-events: auto;
  appearance: none;
  background: transparent;
  border: 0;
  border-radius: var(--r-1);
  color: transparent;
  caret-color: var(--bone-100);
  font-family: var(--font-print, var(--font-varsity)), var(--font-display);
  /* px first for anything without container queries, then the real one. */
  font-size: var(--fs-16);
  font-size: 3.6cqw;
  line-height: 1;
  letter-spacing: var(--track-caps);
  text-align: center;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  overflow: hidden;
  transition: box-shadow var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
/* EVERY FIELD SAYS IT IS ONE, and this is the design's own rule being argued
   with for the second time. "The moulding says what everything is" is true of a
   filled frame and tells a customer nothing about what they may CHANGE — the
   owner asked twice, most plainly on 2026-09-16: "we need some form of
   indication showing that it can be edited". So a field carries a hairline at
   rest whether or not it holds a value.

   THREE WEIGHTS, AND THEY MEAN DIFFERENT THINGS. A filled field is barely
   ringed: it reads correctly already and the ring only says "you can come
   back". An EMPTY one is ringed in accent at half alpha — "this wants a value".
   Hover and focus take the full 2px, which is "here". Thirteen full-weight
   boxes would be a form drawn on top of the part, which is what the frame
   exists to avoid. */
.spc-f {
  box-shadow: inset 0 0 0 var(--bw) color-mix(in srgb, var(--bone-100) 22%, transparent);
}
.spc-f[data-empty] {
  box-shadow: inset 0 0 0 var(--bw) color-mix(in srgb, var(--accent) 52%, transparent);
}
.spc-f:hover, .spc-f:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.spc-f:focus {
  background: var(--ink-950);
  color: var(--bone-100);
  cursor: text;
  outline: none;
  box-shadow: inset 0 0 0 2px var(--accent);
}
/* Each field's type follows the lettering it sits on: the marker's words are
   cast at over a tag's height, and the nameplate carries the generator's wider
   tracking (type track 0.14 against a skill code's 0.1) because a name is read
   as a name and a code is read as a code. */
.spc-f--word { font-size: var(--fs-24); font-size: 6cqw; }
.spc-f--stat { font-size: var(--fs-18); font-size: 4.4cqw; }
/* The stub is a SELECT and nothing about a rectangle says so, so it carries the
   one mark a dropdown always has. Drawn with a border rather than a glyph
   because the field's own text is transparent — the number is moulded on the
   tag behind it — and a caret in the corner is the smallest thing that reads as
   "this opens a list". */
.spc-f--number { position: relative; }
.spc-f--number::after {
  content: ''; position: absolute; right: 8%; top: 50%;
  width: 0; height: 0; margin-top: -0.1em;
  border-left: 0.22em solid transparent;
  border-right: 0.22em solid transparent;
  border-top: 0.24em solid var(--accent);
  pointer-events: none;
}
.spc-f--name { letter-spacing: var(--track-display); }
/* The stub's own text stays hidden even while the list is open: the number is
   cast on the tag behind it, and a select showing "7" over a moulded 7 is the
   same value twice, half a pixel apart. */
.spc-f--number:focus { color: transparent; }

/* In the card's bar now, beside the lettering — it was the last thing left in
   the column when the column went. Hidden unless the marker has been reworded,
   which is most of the time. */
.btn-reset-star-marker {
  display: inline-flex; align-items: center; gap: 4px;
  color: var(--link); font-size: var(--fs-12);
}
.spc-head__reset-label { font-size: var(--fs-12); }
.btn-reset-star-marker:hover .spc-head__reset-label { color: var(--text); }
/* A display value on the same element beats the UA's [hidden] { display: none },
   so the control has to opt back out itself — otherwise "reset the marker"
   offers to reset a marker that is already the stock pair. */
.btn-reset-star-marker[hidden] { display: none; }


.btn-remove-star { color: var(--text-3); }
.btn-remove-star:hover { color: var(--err); }

.star-empty-state { padding: var(--s-12); text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--s-2); }
.star-empty-state__icon { color: var(--text-3); }
.star-empty-state__title { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-18); color: var(--text-2); }
.star-empty-state__hint { font-size: var(--fs-14); color: var(--text-3); }

.star-free-nudge {
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-3) var(--s-4);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  color: var(--text-2); font-size: var(--fs-14);
}
.star-free-nudge--earned { border-color: var(--ok); color: var(--text); background: var(--ok-bg); }
.star-input { font: inherit; }

/* In the review overlay the frame is a PROOF and never a form: no regions, and
   it may shrink as far as it likes because nothing on it is going to be tapped.
   AND IT IS CAPPED — at full width one frame filled the modal and pushed the
   printed tag list, which is the thing a proof-read is actually for, below the
   fold, with the discount row and the total after it. */
.spc-sprue-frame { padding: var(--s-3) 0; }
.spc-sprue-frame--review { padding: var(--s-2) 0; }
.spc-sprue-frame--review .spc-sprue { max-width: 460px; }

/* ============================================================
   12 · REVIEW
   ============================================================ */
.sprue-review-overlay {
  position: fixed; inset: 0; z-index: var(--z-dialog);
  background: var(--scrim); /* lint-allow: raw-colour — token */
  display: flex; align-items: center; justify-content: center; padding: var(--s-4);
  overflow-y: auto;
}
.sprue-review-overlay[hidden] { display: none; }
.sprue-review-modal {
  background: var(--surface); border: var(--bw) solid var(--line-2);
  border-radius: var(--r-2); box-shadow: var(--shadow-2);
  width: min(920px, 100%); display: flex; flex-direction: column;
  max-height: calc(100vh - 2 * var(--s-4)); /* lint-allow: vh — dialog cap, not a page lock */
}
.review-header {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  padding: var(--s-4) var(--s-6); border-bottom: var(--bw) solid var(--line-1);
}
.review-header h2 {
  display: flex; align-items: center; gap: var(--s-2); margin: 0;
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-20); color: var(--text);
}
.review-body { padding: var(--s-6); overflow-y: auto; display: flex; flex-direction: column; gap: var(--s-4); }
.review-footer { padding: var(--s-4) var(--s-6); border-top: var(--bw) solid var(--line-1); display: flex; justify-content: flex-end; gap: var(--s-3); flex-wrap: wrap; }
.review-actions { display: flex; gap: var(--s-3); margin-left: auto; }
.btn-close-review { color: var(--text-3); }
.btn-close-review:hover { color: var(--text); }

.review-team-banner { display: flex; align-items: center; gap: var(--s-3); padding: var(--s-3) var(--s-4); background: var(--bg-alt); border-radius: var(--r-1); }
.review-team-icon { color: var(--accent-2); }
.review-team-details { display: flex; flex-direction: column; gap: 2px; }
.review-team-name { font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-18); }
.review-team-size { font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; }

.review-sprues { display: flex; flex-direction: column; gap: var(--s-4); }
.review-section { display: flex; flex-direction: column; gap: var(--s-2); }
.review-section-header { display: flex; align-items: center; gap: var(--s-2); font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-caps); font-size: var(--fs-13); color: var(--text-2); }
.review-section--discount .review-section-header { color: var(--ok); }
.review-count { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text-3); }
.review-section--discount .review-count { color: var(--ok); }
.review-star-stats { display: flex; gap: var(--s-3); flex-wrap: wrap; font-size: var(--fs-13); color: var(--text-3); }
.review-total, .total, .total-price {
  font-family: var(--font-display); font-weight: 700; font-size: var(--fs-20);
  color: var(--gold-300); font-variant-numeric: tabular-nums;
}
.total-label { font-family: var(--font-display); text-transform: uppercase; letter-spacing: var(--track-display); color: var(--text-2); }
.total-skills { font-size: var(--fs-13); color: var(--text-3); font-variant-numeric: tabular-nums; }

.review-pricing { display: flex; flex-direction: column; gap: var(--s-2); padding-top: var(--s-3); border-top: var(--bw) solid var(--line-1); }
.review-price-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); font-size: var(--fs-14); color: var(--text-2); }
.review-price-row > span:first-child { display: inline-flex; align-items: center; gap: var(--s-2); }
.review-price-row[hidden] { display: none; }
.review-price-row.extra { color: var(--text-3); }
.review-price-row.total {
  padding-top: var(--s-2); border-top: var(--bw) solid var(--line-1);
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-18); color: var(--text);
}
.review-price-row.total span:last-child { color: var(--gold-300); font-size: var(--fs-24); }

/* THE REVIEW DRAWS THE SPRUES (owner decision 2026-09-08). The dialog is
   wider than the other wide dialogs because a row of four 32mm frames wants
   ~1100px; below that the grid drops to three, two, one. */
.modal-review .ctd-dialog { width: min(1180px, 100%); }
.review-frames { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--s-3); padding: var(--s-3); }
.review-frame { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.review-frame svg { width: 100%; height: auto; display: block; }
.review-frame__cap { display: flex; justify-content: space-between; gap: var(--s-2); font-size: var(--fs-12); color: var(--text-3); }
.review-frame__cap b { color: var(--text); font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.review-frame--40 .review-frame__cap b { color: var(--accent-2); }
.review-sheets { display: flex; flex-wrap: wrap; gap: var(--s-4); padding: var(--s-3); --part-px: 0.5px; }
.review-sheets__none { margin: 0; padding: var(--s-3); font-size: var(--fs-13); color: var(--text-3); }
/* ---- the custom positional's frame, and the controls standing on it ----

   THE MINIMUM WIDTH AND THE SCROLLER ARE THE STAR BUILDER'S (.spc-sprue-frame),
   and they are here for the reason they are there: this drawing is 767 viewBox
   units wide with ten fields on it, and below about 520px a tag row is too
   short to press without overlapping the row above it. The alternatives were a
   form on phones and a frame on laptops — two interfaces for one job — or
   fields too small to hit. One interface at every width, and it scrolls. */
.v2-draft-scroll {
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
}
.v2-draft-frame { position: relative; min-width: 520px; max-width: 100%; margin-inline: auto; }
.v2-draft-frame > svg { width: 100%; height: auto; display: block; }

/* The rects the generator laid over the features are GEOMETRY, not controls:
   the HTML field mounted on each one is what gets pressed and typed in.
   Leaving them hittable would mean two overlapping targets per feature and the
   one underneath does nothing. */
.v2-draft-hit { pointer-events: none; }

/* Exactly the drawing's box, because every field inside is positioned as a
   percentage of the viewBox (sprue-fields.js mountDraftFields). `none` on the
   layer and `auto` on the fields keeps the bare runner between two windows
   un-pressable, which is true of the object: the frame is not a control. */
.v2-draft-fields { position: absolute; inset: 0; pointer-events: none; }

/* THE VALUE IS NOT DRAWN BY THE FIELD. It is moulded on the tag underneath,
   which is the whole point, so the control is transparent until it has focus
   and then becomes an opaque plate carrying what is being typed. The second
   state exists because a caret over cast lettering is unreadable.

   THE FACE IS THE TAGS' FACE — --font-varsity is what is physically printed on
   these parts, so a field lettered in anything else would be a different
   object sitting on the frame while you typed in it. */
.v2-draft-f {
  position: absolute;
  left: var(--x); top: var(--y); width: var(--w); height: var(--h);
  margin: 0; padding: 0 2px;
  pointer-events: auto;
  appearance: none;
  background: transparent;
  border: 0;
  border-radius: var(--r-1);
  color: transparent;
  caret-color: var(--bone-100);
  font-family: var(--font-varsity), var(--font-display);
  font-size: var(--fs-14);
  line-height: 1;
  letter-spacing: var(--track-caps);
  text-align: center;
  text-transform: uppercase;
  cursor: pointer;
  overflow: hidden;
  transition: box-shadow var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
/* Each field's type follows the lettering it sits on: the nameplate carries
   the generator's wider tracking (0.14 against a skill code's 0.1) because a
   name is read as a name and a code is read as a code. */
.v2-draft-f--name { letter-spacing: var(--track-display); }
.v2-draft-f--stat { font-variant-numeric: tabular-nums; }

/* A feature nobody has filled in has to be VISIBLE as one, and this is the one
   place the design's own rule has to be argued with. "The moulding says what
   everything is" holds for a FILLED frame and says nothing on an empty one: a
   blank window and a blank compartment are both just dark plastic, and a
   customer opening a fresh positional would be looking at a drawing rather
   than at a form. A HAIRLINE of accent, not the focus ring — ten 2px accent
   boxes on one frame would be a form drawn on top of the part, which is what
   this replaced. */
.v2-draft-f[data-empty] {
  box-shadow: inset 0 0 0 var(--bw) color-mix(in srgb, var(--accent) 52%, transparent);
}
.v2-draft-f:hover, .v2-draft-f:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.v2-draft-f:focus {
  background: var(--ink-950);
  color: var(--bone-100);
  cursor: text;
  outline: none;
  box-shadow: inset 0 0 0 2px var(--accent);
}
.v2-draft-f--skill:focus { cursor: pointer; }

/* The glyph is the only mark a field makes unprompted, and only a skill window
   gets one — a blank window is the one feature whose ring is not enough,
   because "press here for a list of two hundred skills" is not something a
   rectangle can say. A FILLED window keeps it, faint: it is the only thing
   saying a printed tag can be changed. */
.v2-draft-f__hint {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 100%;
  color: var(--accent);
  opacity: 0.8;
  transition: opacity var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.v2-draft-f--skill.is-filled .v2-draft-f__hint { opacity: 0; color: var(--bone-100); }
.v2-draft-f--skill:hover .v2-draft-f__hint,
.v2-draft-f--skill:focus-visible .v2-draft-f__hint { opacity: 1; }

/* printed-sprue preview blocks */
.sprue-block { border: var(--bw) solid var(--line-2); border-radius: var(--r-2); overflow: hidden; }
.sprue-header { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); padding: var(--s-2) var(--s-3); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1); }
.sprue-label { display: inline-flex; align-items: center; gap: var(--s-2); font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-14); }
.sprue-meta { display: inline-flex; align-items: center; gap: var(--s-2); }
.sprue-badge {
  display: inline-block; padding: 2px var(--s-2); border-radius: var(--r-pill);
  border: var(--bw) solid var(--line-2); font-size: var(--fs-12); color: var(--text-3);
  white-space: nowrap; /* "15 × 32mm" broke into two lines in the phone review */
}
.sprue-badge--32mm { border-color: var(--line-2); }
.sprue-badge--40mm { border-color: var(--violet-600); color: var(--accent-2); }
.sprue-count { font-size: var(--fs-12); color: var(--text-3); font-variant-numeric: tabular-nums; }
.sprue-tags { display: grid; gap: 4px; padding: var(--s-3); }
.sprue-tags--6col { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.sprue-tags--8col { grid-template-columns: repeat(8, minmax(0, 1fr)); }
.sprue-tags--player { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); }
.sprue-tag {
  display: flex; align-items: center; justify-content: center;
  min-height: 28px; padding: 2px 4px;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  font-size: var(--fs-12); color: var(--text-2); text-align: center; overflow-wrap: anywhere;
}
.sprue-tag--empty { border-style: dashed; color: var(--text-3); }
.sprue-tag--skill { background: var(--surface-2); }
.sprue-tag--skill-40 { border-color: var(--violet-600); }
.sprue-tag--positional { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); font-weight: 700; }

/* The 40mm accent on the review roster block (ui/team/render.js passes
   accent: '40'), so big-guy bases read differently from the 32mm list. */
.review-roster--40 .rr-num { color: var(--accent-2); }
.review-roster--40 .rr-tag { border-color: var(--violet-600); }
.review-roster { display: flex; flex-direction: column; }
.review-roster__row { display: grid; grid-template-columns: 48px 1fr 2fr; gap: var(--s-2); align-items: center; padding: var(--s-2) var(--s-3); border-bottom: var(--bw) solid var(--line-1); }
.review-roster__row:last-child { border-bottom: 0; }
.rr-num { font-variant-numeric: tabular-nums; color: var(--text-3); font-size: var(--fs-13); }
.rr-name { font-size: var(--fs-14); }
.rr-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.rr-tag {
  display: inline-flex; align-items: center; padding: 2px 6px;
  border: var(--bw) solid var(--line-2); border-radius: var(--r-1);
  font-size: var(--fs-12); color: var(--text-2);
}
.rr-tag--empty { border-style: dashed; color: var(--text-3); }
.rr-tag--pos { background: var(--surface-2); font-weight: 600; }

/* ============================================================
   13 · SUMMARY / ACTION BAR   REWORKED 2026-09-07
   #stickyBottomBar. A FIXED FULL-WIDTH BAR AT EVERY WIDTH.

   Owner: "move the summary bit to a bar at the bottom that is sticky."

   It used to be a 320px sticky aside above 1000px and only became a bar on
   phones. The aside is gone entirely: the roster wants the width more than
   five short chips want a column of their own (see the layout note at the top
   of this file), and the chips lose nothing by lying down   they were already
   a single row of label + value each.

   THE DESKTOP BAR IS RICHER THAN THE PHONE BAR, which is the one thing that
   is not just "the phone rules, everywhere". The phone version hides every
   chip but the price because 375px has room for a price and a button. A
   1680px bar has room for all five chips WITH their micro-bars, the total and
   Review, so it shows them   the whole point of moving it was to stop the
   summary costing anything, not to show less of it. The chips go back to
   being hidden in the <=768 block.
   ============================================================ */
.stats-bar-wrapper {
  position: fixed; left: 0; right: 0; bottom: 0;
  z-index: var(--z-sticky);
  background: var(--bg-alt);
  border: var(--bw) solid var(--line-1);
  border-width: var(--bw) 0 0;
  border-radius: 0;
  box-shadow: var(--fx-rim);
  padding-bottom: env(safe-area-inset-bottom);
}
.stats-bar-wrapper.hidden-bar { display: none; }
/* The bar's contents share the builder's measure so the total and Review sit
   under the roster's right-hand edge rather than out at the viewport's. The
   BAR is full-bleed (it is a shelf across the page); its CONTENT is not. */
.stats-bar-inner {
  display: flex; flex-direction: row; align-items: center;
  gap: var(--s-5);
  width: min(100% - 2 * var(--s-6), var(--wrap));
  margin-inline: auto;
  min-height: var(--actionbar-h);
  padding: var(--s-2) 0;
}
.stats-bar-title { color: var(--text-3); flex: none; }
/* The server-recheck line is a footnote and a fixed bar has no room for a
   footnote. It is not deleted   it is the kind of sentence somebody put there
   for a reason   but it belongs where there is space, which on this layout is
   nowhere on the bar. Kept in the DOM for the copy, hidden from the bar. */
.stats-bar-note { margin: 0; display: none; }

.stat-chip { display: flex; flex-direction: column; gap: 3px; flex: none; min-width: 0; }
.stat-chip-top { display: flex; align-items: center; gap: var(--s-2); }
.stat-chip-top .ctd-i { color: var(--text-3); flex: none; }
.stat-label { font-size: var(--fs-13); color: var(--text-2); }
.stat-value { margin-left: auto; font-size: var(--fs-14); color: var(--text); font-variant-numeric: tabular-nums; }
.stat-chip[hidden] { display: none; }
.stat-micro-bar { height: 4px; border-radius: var(--r-pill); background: var(--bg-alt); overflow: hidden; }
.stat-micro-fill { height: 100%; width: 0; background: var(--ok); transition: width var(--t-base) var(--ease); }
.stat-micro-fill--gold { background: var(--accent); }
.stat-micro-fill--purple { background: var(--accent-2); }
.stat-danger .stat-micro-fill { background: var(--err); }
.tv-suffix { color: var(--text-3); }

/* The total is pushed to the right-hand end and Review sits after it, so the
   bar reads left-to-right as "what you have chosen ... what it costs ... go".
   No top border and no top padding: it is a sibling in a row now, not the
   last block of a stacked card. */
.price-stat {
  flex-direction: row; align-items: baseline; gap: var(--s-3);
  margin-left: 0; padding-top: 0; border-top: 0;
}
.price-label { font-family: var(--font-display); text-transform: uppercase; letter-spacing: var(--track-display); color: var(--text-2); }
.price-value { font-family: var(--font-display); font-weight: 700; font-size: var(--fs-24); color: var(--gold-300); font-variant-numeric: tabular-nums; }
/* Struck-through pre-discount price (star bundle of 5). */
.price-was { text-decoration: line-through; color: var(--text-3); font-size: var(--fs-16); }

/* .show-for-sprue / .show-for-star are pathway bookkeeping on the bar — the
   LEGACY stylesheets carried no rules for them either, and the per-pathway
   differences (the "Players" chip becoming "Sprues", the 40mm chip appearing)
   are all written by ui/sprue and ui/star as text and `hidden`. Inventing CSS
   here would fight that: a `.show-for-star .skills-40mm-stat { display: none }`
   would override the JS's own decision to show it. They stay rule-less, with
   an ALLOW entry in tools/contract/builder-css.mjs saying why. */
.show-stats .stats-bar-inner { display: flex; }

/* Back / Next. It was phone-only, which was survivable while every step was
   on screen at once and there was nothing to step through; with the gate in 2B
   restored it is the only way forward on a desktop, so it shows at every width.
   >=1000px it sits under the summary card in the aside; <=768px it is the top
   row of the fixed action bar. `aria-hidden` is the wizard's own off switch. */
/* Back / Next: the bar's SECOND row, at every width. It was the aside's
   bottom block above 1000px and the action bar's top row below; with the
   aside gone there is one answer   a row under the chips, on the same
   measure. */
.mw-nav__btn { flex: none; }
.mw-nav__label { font: inherit; }
/* Where you are: the step count over the step's title, after Back at the
   left of the row. `margin-right: auto` is what pushes the chips, the total
   and the primary to the right-hand end. */
.mw-nav__status { display: flex; flex-direction: column; gap: 0; min-width: 0; margin-right: auto; line-height: 1.25; }
.mw-nav__step { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); white-space: nowrap; }
.mw-nav__title { font-size: var(--fs-13); color: var(--text-2); white-space: nowrap; }
/* A toast's action is a <button> now as well as the sticky notice's <a>; same
   look, the button chrome reset. */
button.ctd-toast__action { background: none; border: 0; padding: 0; margin-left: var(--s-2); font: inherit; font-weight: 700; cursor: pointer; }

/* THE TOAST HOST AND THE BACK-TO-TOP BUTTON HAVE TO CLEAR THE BAR, and now
   that the bar is fixed at every width, so does this. chrome.css lifts the
   toast host only at <=768 and only by one row; both of these land on top of
   Next otherwise. Two rows' worth, because the bar carries Back/Next under
   the chips whenever the wizard is running. */
body.ctd-app-page .ctd-toasts,
body.ctd-app-page .back-to-top {
  bottom: calc(var(--actionbar-h) + var(--s-3) + env(safe-area-inset-bottom));
}

/* ============================================================
   14 · SAVED BUILDS PANEL
   ============================================================ */
.saved-builds-panel { background: var(--surface); border: var(--bw) solid var(--line-2); border-radius: var(--r-2); overflow: hidden; }
.saved-builds-panel[hidden] { display: none; }
.saved-builds-panel__header { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); padding: var(--s-3) var(--s-4); background: var(--bg-alt); border-bottom: var(--bw) solid var(--line-1); }
.saved-builds-panel__header h3 { display: flex; align-items: center; gap: var(--s-2); margin: 0; font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.saved-builds-panel__close { color: var(--text-3); }
.saved-builds-panel__list { padding: var(--s-3) var(--s-4); display: flex; flex-direction: column; gap: var(--s-3); }
.saved-builds-panel__empty { display: flex; align-items: center; gap: var(--s-2); margin: 0; color: var(--text-3); font-size: var(--fs-14); }

.saved-build { border: var(--bw) solid var(--line-1); border-radius: var(--r-1); padding: var(--s-3); display: flex; flex-direction: column; gap: var(--s-2); }
.saved-build__header { display: flex; align-items: center; gap: var(--s-3); }
.saved-build__logo-wrap { width: 40px; height: 40px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: var(--r-1); background: var(--bg-alt); overflow: hidden; color: var(--text-3); }
.saved-build__logo-img { width: 100%; height: 100%; object-fit: contain; }
.saved-build__title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.saved-build__name { font-family: var(--font-display); font-weight: 500; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-16); }
.saved-build__preset { font-size: var(--fs-12); color: var(--text-3); }
.saved-build__date { display: flex; align-items: center; gap: 6px; font-size: var(--fs-12); color: var(--text-3); }
.saved-build__stats { display: flex; gap: var(--s-4); flex-wrap: wrap; }
.saved-build__stat-col { display: flex; flex-direction: column; gap: 2px; }
.saved-build__stat-label { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.saved-build__stat-value { font-size: var(--fs-14); font-variant-numeric: tabular-nums; }
.saved-build__actions { display: flex; gap: var(--s-2); margin-left: auto; }

/* ============================================================
   15 · DIALOG INTERNALS
   The wrappers are .ctd-overlay / .ctd-dialog (chrome.css + components.css);
   these are the builder's own contents plus the aliases the JS still queries.
   ============================================================ */
.ctd-dialog--wide { width: min(920px, 100%); }
.ctd-dialog--confirm { width: min(420px, 100%); text-align: center; }
.ctd-dialog__icon { color: var(--accent-2); }
.ctd-dialog__title { margin: 0; font-family: var(--font-display); font-weight: 700; text-transform: uppercase; letter-spacing: var(--track-display); font-size: var(--fs-20); color: var(--text); }
.ctd-dialog__text { margin: 0; color: var(--text-2); }
.ctd-dialog--confirm .ctd-dialog__foot { justify-content: center; }
.ctd-overlay--confirm { align-items: center; }

/* .modal-header is queried by ui/team/skills-modal.js; the rest are aliases
   kept so the dialogs read the same in markup and in the JS. */
.modal-header { display: flex; }
.modal-header h3 {
  display: flex; align-items: center; gap: var(--s-2); margin: 0;
  font-family: var(--font-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-20); color: var(--text);
}
.modal-body { min-height: 0; overflow-y: auto; }
.modal-footer { flex-wrap: wrap; }
.modal-close { color: var(--text-3); }
.modal-close:hover { color: var(--text); }
.skill-modal .modal-body { gap: var(--s-3); }

/* WIDER THAN ITS SIBLINGS, because the frame has a floor and the settings do
   not. The shared --wide dialog is 920, which leaves the frame pane 519 — one
   pixel under the 520 a pressable tag row needs, so the scroller drew a bar
   over a single pixel of overflow on a 1024 laptop. 1120 clears it with room
   for the drawing to grow past its minimum instead of sitting on it. */
.custom-positional-modal { width: min(1120px, 100%); }

/* THE FRAME TAKES THE ROOM AND THE SETTINGS TAKE A COLUMN (2026-09-12). It
   was the other way round — a 280px picture of the part beside a two-column
   form describing it. The part is where the work happens now, and 280px is
   what the four things NOT cast on it need. */
.custom-positional-modal__layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--s-6); }
.custom-positional-modal__frame-pane { display: flex; flex-direction: column; gap: var(--s-3); padding: var(--s-4); background: var(--bg-alt); border-radius: var(--r-1); min-width: 0; }
.custom-positional-modal__form-pane { min-width: 0; }
.custom-positional-modal__hint { margin: 0; font-size: var(--fs-12); color: var(--text-3); }
.custom-positional-panel-eyebrow { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); }
.custom-positional-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-4); }
.custom-positional-field { display: flex; flex-direction: column; gap: var(--s-2); border: 0; padding: 0; margin: 0; min-width: 0; }
.custom-positional-field--full { grid-column: 1 / -1; }
.custom-positional-label { font-size: var(--fs-12); text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--text-3); padding: 0; }
.custom-positional-label__optional { text-transform: none; letter-spacing: 0; }
.custom-positional-help { font-size: var(--fs-12); color: var(--text-3); }
.custom-positional-base-size { display: flex; gap: var(--s-2); }
.custom-base-size-option { cursor: pointer; }
.custom-base-size-option input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.custom-base-size-option__pill {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 64px; min-height: var(--control-h-sm); padding: 0 var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-pill);
  color: var(--text-2); font-size: var(--fs-14);
}
input:checked + .custom-base-size-option__pill { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }
input:focus-visible + .custom-base-size-option__pill { outline: 2px solid var(--focus); outline-offset: 2px; }
.custom-positional-access__chips { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.custom-access-chip {
  display: inline-flex; align-items: center; gap: 6px;
  min-height: var(--control-h-sm); padding: 0 var(--s-3);
  border: var(--bw) solid var(--line-2); border-radius: var(--r-pill);
  color: var(--text-2); font-size: var(--fs-13);
}
.custom-access-chip.is-active, .custom-access-chip.selected { background: var(--bone-100); color: var(--ink-950); border-color: var(--bone-100); }
.custom-positional-stepper { position: relative; display: flex; align-items: stretch; }
.custom-positional-stepper__input { flex: 1; padding-right: var(--s-8); font-variant-numeric: tabular-nums; }
.custom-positional-stepper__buttons { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: var(--s-8); }
.custom-positional-stepper__btn { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-3); border-left: var(--bw) solid var(--line-2); }
.custom-positional-stepper__btn:hover { color: var(--text); }
.custom-positional-stepper__btn--up { border-bottom: var(--bw) solid var(--line-2); }

/* ============================================================
   16 · FEEDBACK: tooltip, spinner, loading states
   ============================================================ */
.info-tooltip {
  position: fixed; z-index: var(--z-toast);
  width: min(280px, calc(100vw - 24px));
  background: var(--surface); border: var(--bw) solid var(--line-2);
  border-radius: var(--r-1); box-shadow: var(--shadow-2);
  opacity: 0; transition: opacity var(--t-fast) var(--ease);
}
.info-tooltip.show { opacity: 1; }
.info-tooltip-header { display: flex; align-items: center; justify-content: space-between; gap: var(--s-2); padding: var(--s-2) var(--s-3); border-bottom: var(--bw) solid var(--line-1); font-weight: 700; font-size: var(--fs-13); }
.info-tooltip-close { color: var(--text-3); }
.info-tooltip-body { padding: var(--s-3); font-size: var(--fs-13); color: var(--text-2); line-height: var(--lh-body); }

/* The one spinner. Replaces Font Awesome's fa-spin. */
.ctd-spin {
  display: inline-block; width: 18px; height: 18px; vertical-align: -3px;
  border: 2px solid var(--line-2); border-top-color: var(--accent);
  border-radius: var(--r-pill);
  animation: ctd-spin 720ms linear infinite;
}
@keyframes ctd-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ctd-spin { animation-duration: 2400ms; } }

/* !!! specificity: .btn--primary sets `color`, is declared later and has the
   same weight, so a bare `.is-loading { color: transparent }` lost and the
   label stayed legible under the spinner. Chaining raises the weight. */
.is-loading { position: relative; pointer-events: none; }
.is-loading, .btn.is-loading, .ctd-btn.is-loading { color: transparent; }
.is-loading::after {
  content: ''; position: absolute; inset: 0; margin: auto;
  width: 16px; height: 16px;
  border: 2px solid var(--line-2); border-top-color: var(--accent);
  border-radius: var(--r-pill);
  animation: ctd-spin 720ms linear infinite;
}

.edit-loading { display: flex; flex-direction: column; align-items: center; gap: var(--s-4); padding: var(--s-12) var(--s-4); text-align: center; }
.edit-loading p { margin: 0; color: var(--text-2); }
.edit-loading--error .ctd-i { color: var(--err); }

.empty-state { padding: var(--s-12); text-align: center; color: var(--text-3); }

/* ============================================================
   17 · LEGACY BUTTON ALIASES
   ui/saves-panel.js, ui/edit-restore.js and ui/team/render.js still inject
   `.btn .btn--primary`; the template uses .ctd-btn. One look for both.
   ============================================================ */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s-2);
  min-height: var(--control-h); padding: 0 var(--s-5);
  border: var(--bw) solid transparent; border-radius: var(--r-1);
  font-family: var(--font-display); font-weight: 500; text-transform: uppercase;
  letter-spacing: var(--track-display); font-size: var(--fs-14);
  color: var(--text); background: transparent;
  transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.btn--primary { background: var(--accent); color: var(--on-gold); }
.btn--primary:hover { background: var(--accent-hover); }
.btn--secondary, .btn--outline { border-color: var(--line-2); color: var(--text); }
.btn--secondary:hover, .btn--outline:hover { border-color: var(--bone-500); }
.btn--ghost { color: var(--text-2); }
.btn--ghost:hover { color: var(--text); }
.btn--sm { min-height: var(--control-h-sm); padding: 0 var(--s-3); font-size: var(--fs-13); }
.btn:disabled { opacity: 0.45; pointer-events: none; }
#btnReview:disabled { cursor: default; }
.btn-text { font: inherit; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center; min-width: var(--control-h-sm); min-height: var(--control-h-sm); color: var(--text-3); }
.btn-icon:hover { color: var(--text); }
.btn-clear { color: var(--text-3); font-size: var(--fs-13); }
.btn-clear:hover { color: var(--err); }

/* ============================================================
   18 · SHARED STATE CLASSES
   The runtime markers ui/** toggles. Each needs a rule or the state is
   invisible — which is exactly what gate:builder-css exists to catch.
   ============================================================ */
/* .is-open is set by js/ctd-ui.js on a .ctd-overlay and by the bench browser
   toggle. It must NOT declare `display`: builder.css loads after chrome.css,
   so a bare `.is-open { display: block }` outranks `.ctd-overlay { display:
   flex }` and every dialog loses its centring. Visibility is the [hidden]
   attribute's job. */
.is-scrollable { overflow-y: auto; }
.is-filtered-out { display: none; }
.is-placeholder { color: var(--text-3); }
.added { border-color: var(--ok); }
.exiting { opacity: 0; transition: opacity var(--t-fast) var(--ease); }
.show { opacity: 1; }
.hidden-bar { display: none; }
.tags { display: flex; flex-wrap: wrap; gap: 4px; }

/* Colour-blind mode is a token swap only — the seven --cat-* hues, redefined
   in css/v3/tokens.css (the one file allowed literal colour). Every dot, chip
   and category header keys off those tokens, so there is nothing to restyle
   here; this comment exists so the next reader does not go looking. */

/* ============================================================
   19 · RESPONSIVE
   ============================================================ */

/* ---------- <=1099px: the beats get their own row ----------
   Title + five beats + Save/Load stopped fitting one line somewhere under
   1100, and what wrapped was the last beat ("5 Review") on its own — a rail
   reading as four steps and an orphan. Give the strip a whole row and let it
   scroll sideways instead. */
@media (max-width: 1099px) {
  /* ONE ROW (declunk 2026-09-08). The beats used to drop to a second row here,
     making the pinned bar 104px on tablets and phones. Below 1100 the labels
     go and the numerals carry the rail — the current beat keeps its word — so
     the title, four beats and icon-only Save/Load share the one 56px row. */
  .ctd-builderbar__inner { flex-wrap: nowrap; }
  .ctd-builderbar__steps .v2-stepper { flex-wrap: nowrap; }
  .ctd-builderbar__steps .ctd-step { white-space: nowrap; flex: none; }
  .v2-stepper__step { padding: 0 var(--s-2); }
  .v2-stepper__rule { width: var(--s-2); }
  .v2-stepper__label { display: none; }
  .v2-stepper__step.is-current .v2-stepper__label { display: inline; }
  .ctd-builderbar__actionlabel { display: none; }
  .ctd-builderbar__actions .ctd-pill { width: var(--control-h-sm); padding: 0; justify-content: center; }
}

/* ---------- >=1000px: two columns, sticky summary ---------- */
@media (min-width: 1000px) {
  /* NO TWO-COLUMN RULE HERE ANY MORE, and the deletion is the change: the
     shell is one column at every width and #stickyBottomBar is fixed to the
     bottom of the viewport (section 13). What used to live here was
     `grid-template-columns: minmax(0,1fr) 320px`, a `position: sticky` for
     the aside, and then three exceptions putting the landing and steps 1-2
     BACK to one column because they had no summary worth a column. All four
     rules and the paragraph explaining the exceptions went with the aside   a
     layout with no exceptions does not need them.

     `.roster-table-split` keeps its two columns: that is the TABLE view's own
     1-8 / 9-16 split and has nothing to do with the shell. */
  .roster-table-split { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  /* The bench follows the roster down. Step 3 is the longest step and the two
     halves of it are "pick a positional" and "see where it landed" — with the
     bench in normal flow you scroll away from the controls to read the result
     and back up to use them again. Its containing block is
     .positional-section, so it releases at the end of the roster.

     THE HEIGHT CAP IS GONE because the strip has no height to cap: it is one
     row of 152px cards that scrolls sideways, so it is about 92px whatever the
     team is. `max-height: min(380px, 38vh)` existed to stop a 20-positional
     bench eating a laptop screen, and a single row cannot. */
  .builder-app--v2[data-mwstep="roster"] .positional-picker-area {
    position: sticky;
    top: calc(var(--builderbar-h) + var(--s-3));
    z-index: 2;
  }
}

@media (max-width: 999px) {
  .custom-positional-modal__layout { grid-template-columns: 1fr; }
}

/* ---------- the bar, at narrower widths ----------
   MOST OF THIS BLOCK IS GONE, and the deletion is the point. It used to carry
   the whole fixed-bar treatment   position, the full-bleed border, the
   safe-area padding, the shell's bottom reserve   because above 1000px the
   summary was a 320px aside and only became a bar below it. The bar is the
   layout at EVERY width now (section 13), so every one of those rules moved
   up there and repeating them here would be two places to change.

   What is left is the part that was always genuinely about narrow screens:
   below 1000px the bar cannot hold five chips and their micro-bars, so it
   keeps the price and Review and drops the rest. That is a real breakpoint
   decision rather than a layout mode.

   The toast host and the back-to-top button clear the bar at every width now
   for the same reason, so those two moved up as well   see the end of
   section 13. */
@media (max-width: 999px) {
  .stats-bar-inner { gap: var(--s-3); }
  .stats-bar-title, .stat-micro-bar { display: none; }
  .stats-bar-inner .stat-chip:not(.price-stat) { display: none; }
  .price-stat { flex: none; flex-direction: column; align-items: flex-end; gap: 0; }
  .price-label { font-size: var(--fs-12); color: var(--text-3); }
  #btnReview { flex: none; }
}

/* ---------- <=768px: phone layout, bench sheet, touch targets ---------- */
@media (max-width: 768px) {
  /* THE LADDER SWIPES ON A PHONE. This said "one band per row", which was
     right for two bands and catastrophic for seven rungs: 7 x 84px = 636px of
     price table before the team grid (measured 2026-09-08). A single row that
     scrolls sideways costs 84px and keeps every rung — the same idiom as the
     bench strip one step later, and the third chip cut off at the edge is what
     says there is more. */
  .v2-tier-strip {
    display: grid; grid-auto-flow: column; grid-auto-columns: 116px;
    grid-template-columns: none;
    overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin;
    padding-bottom: var(--s-1);
  }
  /* A 116px chip cannot hold "8 tags · 1 big guy" on one line, and a wrapped
     dot separator leaves the second line starting with a stray middot. Stack
     the two facts and drop the separator. */
  .v2-tier__spec { flex-direction: column; gap: 0; }
  .v2-tier__spec-row + .v2-tier__spec-row::before { content: none; }
  .v2-size-extras-line { font-size: var(--fs-13); }

  /* The scratch banner stacks; the crest grid gets three across so a phone
     shows a dozen teams at a time instead of four. */
  .v2-team-tile--scratch { flex-direction: column; align-items: flex-start; text-align: left; }
  .v2-scratch__body { flex: 1 1 auto; }
  .v2-scratch__cta { margin-left: 0; }
  .v2-team-grid { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
  .v2-team-tile__crest { width: 44px; height: 44px; }

  .ctd-builderbar__inner { gap: var(--s-2); }
  /* THE TITLE GOES, THE BEATS STAY. At 390 the row is back arrow + title +
     four beats + Save + Load, and the beats' 247px stepper was overflowing an
     82px box — painting over Save/Load rather than clipping (measured
     2026-09-08). The title is the one thing on the row that is said elsewhere:
     the builder bar says "Blitz Builder" and the step's own H2 says the rest. The
     beats are a control (tap a finished one to go back), so they keep the
     space, and the box scrolls if a language ever makes them wider. */
  .ctd-builderbar__id { display: none; }
  .ctd-builderbar__steps { flex: 1 1 auto; min-width: 0; overflow-x: auto; justify-content: flex-start; scrollbar-width: none; }
  .ctd-builderbar__steps::-webkit-scrollbar { display: none; }
  /* NUMERALS ONLY, INCLUDING THE CURRENT BEAT (the >=769 rail keeps the word
     on the current one). Four beats plus one label measured 262px in a 184px
     box, so two of them sat off the end of a scroller nobody would think to
     swipe. The bottom bar says "Step 2 of 4 · Build roster" on the same
     screen, so the word is not lost — and four numerals fit. */
  .v2-stepper { gap: 2px; }
  .v2-stepper__step { padding: 0 6px; }
  .v2-stepper__rule { width: 6px; }
  .v2-stepper__step.is-current .v2-stepper__label { display: none; }
  .ctd-builderbar__title { font-size: var(--fs-16); }
  /* Icon-only Save/Load (labels went at 1099) at the 44px touch size the
     block below gives every other control here. */
  .ctd-builderbar__actions .ctd-pill { width: var(--control-h); }
  .ctd-builderbar .header-back-btn { width: var(--control-h); height: var(--control-h); }

  /* The bar on a phone: Back as a square, the step count without its title,
     the bare total (the gold number is the label), the primary with tighter
     padding. Measured 344px of content in a 327px row before these. */
  #mwPrev .mw-nav__label, .mw-nav__title, .price-label { display: none; }
  .stats-bar-inner { gap: var(--s-2); }
  #btnReview, #mwNext { padding-inline: var(--s-3); }
  .price-value { font-size: var(--fs-20); }

  .builder-hero__pathways { grid-template-columns: 1fr; }
  .star-add-strip { grid-template-columns: 1fr; }
  .available-skills-grid, .category-skills { grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); }

  /* ---------- the bench as a bottom sheet ---------- */
  /* The handle sticks under the builder bar: the roster below it is 16 cards
     tall on a phone and "Add players" used to scroll off with the first. */
  .builder-bench-toggle { display: inline-flex; position: sticky; top: calc(var(--builderbar-h) + var(--s-2)); z-index: 3; }
  .builder-bench-toggle[hidden] { display: none; }
  .builder-bench-close {
    display: inline-flex; position: static; order: -1; align-self: flex-end;
    margin: var(--s-2) var(--s-2) 0;
  }
  .positionals-scroll-wrapper { order: 1; }

  .positional-picker-area {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-dialog);
    max-height: 78vh; /* lint-allow: vh — sheet cap, not a page lock */
    display: flex; flex-direction: column;
    border-radius: var(--r-2) var(--r-2) 0 0;
    border-width: var(--bw) var(--bw) 0;
    transform: translateY(100%);
    transition: transform var(--t-base) var(--ease);
  }
  /* THE SHEET IS THE ONE PLACE THE STRIP DOES NOT APPLY. On a phone the bench
     is a full-height bottom sheet, so it has vertical room and no horizontal
     room at all — a sideways scroller inside a 78vh panel would be a row of
     four with eleven off-screen and a second scroll axis to find them. It goes
     back to a wrapping grid, and the cards go back to filling their column
     rather than the strip's fixed 152px. */
  .positionals-scroll-wrapper {
    flex: 1; overflow-x: hidden; overflow-y: auto;
    max-height: none; padding-bottom: env(safe-area-inset-bottom);
  }
  .positionals-grid {
    display: grid; width: auto;
    grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
    gap: var(--s-3);
  }
  .positionals-grid .pos-card { width: auto; }
  .positionals-grid .custom-pos-card--empty { width: auto; }
  /* Vertical room means the skills and meta lines earn their place back. */
  .positionals-grid .pos-card-extra { display: block; }
  /* The source tile heads the sheet's grid as a full-width row, and the team
     popover drops over the sheet's cards rather than off the bottom of it. */
  .v2-bench-source { position: static; width: auto; grid-column: 1 / -1; flex-direction: row; align-items: center; justify-content: space-between; }
  .v2-bench-browser { top: var(--s-10); left: var(--s-3); right: var(--s-3); width: auto; }

  body.ctd-bench-open .positional-picker-area { transform: none; }
  body.ctd-bench-open .stats-bar-wrapper { display: none; }
  body.ctd-bench-open::before {
    content: ''; position: fixed; inset: 0; z-index: var(--z-sticky);
    background: var(--scrim); /* lint-allow: raw-colour — token */
  }

  .custom-positional-grid { grid-template-columns: 1fr; }
  .review-roster__row { grid-template-columns: 40px 1fr; }
  /* The review's drawn frames go two across on a phone: one 267px frame per
     row made the dialog body 4,300px. At ~150px the printed codes are below
     the 12px floor — accepted HERE because this is the confirmation overview
     (the roster step shows every frame full size), and the caption carries
     the number and name. */
  .review-frames { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-2); padding: var(--s-2); }

  /* THE ROSTER HEADER IS ONE ROW ON A PHONE (declunk follow-up 2026-09-08). It
     wrapped to two (94px): the "Roster" title the rail already carries, the
     team name with a "Rename" word, the count, the brush and three switches.
     Kept: name (tap or the pencil to rename), count, brush, colour-blind. The
     full-names and TV switches are cosmetic on the drawn cards and go. */
  .roster-header { flex-wrap: nowrap; gap: var(--s-2); }
  .roster-header > h3 { display: none; }
  .roster-header .team-name-display-group { flex: 1 1 auto; min-width: 0; }
  .roster-header .team-name-display { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .roster-header .team-name-rename-btn__label { display: none; }
  .roster-header .roster-table-actions { flex: none; flex-wrap: nowrap; gap: var(--s-2); margin-left: 0; }
  .roster-header .show-tags-toggle, .roster-header .show-tv-toggle { display: none; }
  .review-roster__row .rr-tags { grid-column: 1 / -1; }
  .sprue-tags--8col { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .sprue-tags--6col { grid-template-columns: repeat(3, minmax(0, 1fr)); }

  /* 44px touch targets (DESIGN-SYSTEM.md §7). */
  .filter-btn, .v2-tagchip, .sprue-capacity-option, .v2-tagstep-option, .custom-base-size-option__pill,
  .qty-btn, .ctd-pill, .mw-nav__btn { min-height: var(--control-h); }
  .qty-btn { width: var(--control-h); }
}


/* ============================================================
   20 · THE SITE'S COMPONENT LANGUAGE
   Owner, 2026-09-08: "all its components sorting to look like the rest
   of the page."

   THE BUILDER WAS BUILT BEFORE THE ROOM WAS. Every panel in this file is a
   flat `var(--surface)` box with a hairline and a radius — which is exactly
   what the storefront looked like in Phase 4, and nothing like what it looks
   like now. Between then and now the site grew a material vocabulary: a card
   is lit from above (--fx-card-ground), a section is headed by a bronze
   nameplate bolted to a cross member, a framed panel is held at its corners
   by steel gussets, a button is a bolted plate, and a thing you can pick
   answers the pointer with a gold bar along its top edge. The builder wears
   none of it, and that — rather than any one component — is why it reads as a
   different site.

   WHAT THIS SECTION DOES NOT DO, and the restraint is the same one the
   effects block below states: the builder is a TOOL. It gets the materials,
   not the theatre. No lift on hover, no rake across artwork, no ambient
   motion, no generated surfaces behind working copy. The devices adopted here
   are the ones that carry MEANING — what is a panel, what is a heading, what
   can be clicked, what is chosen.

   IT IS ALL RE-DRESSING, NO RESTRUCTURING. Every selector below already
   existed; nothing moves, nothing is renamed, and the only markup that
   changed is a heading becoming a plaque and three panels gaining four
   gussets each. That is deliberate — the layout was measured and settled
   earlier the same day (§7 of docs/plans/blitz-builder-declunk/PLAN.md) and
   this pass must not spend those numbers back.
   ============================================================ */

/* ---------- the nameplate ----------
   A storefront section hangs its plaque across the steel cross member on its
   top edge, and .ctd-plate translates up by half itself to straddle it. The
   builder's steps have no member — they are workspaces inside one shell — so
   the plaque sits IN the flow here. Everything else about it (the bronze, the
   chamfer, the four rivets, the Varsity lettering) is the component's own. */
.builder-app--v2 .ctd-plate { transform: none; margin-bottom: 0; }
/* A step heading is a rank below a section heading on the storefront, and the
   plaque is sized off --plate-fs, so it comes down a step with it. */
.builder-app--v2 .ctd-plate { --plate-min-w: 220px; }
.builder-app--v2 .ctd-plate__title { font-size: var(--fs-20); }

/* ---------- the card ground ----------
   `.ctd-card` is `var(--fx-card-ground), var(--surface)`: a top-lit wash over
   the panel colour, which is what makes a storefront card look like a lit
   object rather than a filled rectangle. Layered as background-IMAGE so each
   element keeps its own background-COLOUR — a panel on --surface and a header
   strip on --bg-alt both light correctly from one rule.

   THE TRAP THAT COMES WITH THAT: a `background:` shorthand anywhere else
   resets background-image to none, so every state rule in this file that
   re-colours one of these elements would blank the ground. Those states are
   re-stated at the end of this block, as background-color. */
.builder-intro,
.builder-hero,
.builder-hero__pathway,
.v2-format-panel,
.v2-extras,
.v2-tier,
.v2-team-tile,
.pos-card,
.roster-player-card,
.extra-tag-card,
.skill-card,
.sprue-card,
.star-add-card,
.saved-builds-panel,
.saved-build,
.paint-panel,
.v2-bench-source,
.positional-picker-area,
.custom-positional-modal__frame-pane {
  background-image: var(--fx-card-ground);
}

/* ---------- the moonlight rim ----------
   The lit top edge every panel on the site carries. The list below the
   effects header already gave it to the small cards; these are the PANELS,
   which had none and so sat flat against a page full of lit ones. */
.builder-intro,
.builder-hero,
.builder-hero__pathway,
.v2-format-panel,
.v2-extras,
.roster-player-card,
.saved-builds-panel,
.saved-build,
.paint-panel,
.positional-picker-area,
.v2-bench-source,
.star-add-card {
  box-shadow: var(--fx-rim);
}

/* ---------- corner gussets ----------
   Three panels are BOLTED IN rather than drawn on: the bench, the saved-builds
   panel and the landing hero. They are the builder's three biggest fixed
   surfaces, and the storefront's rule is that a framed panel is held at its
   corners by steel cut from the same stock as the girders (ornament.css). The
   working cards inside them are not framed — hardware on every card would be
   a hardware shop, and the compare cards on the home page are the precedent
   for using it sparingly. `position: relative` is what the absolute corners
   need; the hero and the panel had none. */
.builder-hero, .saved-builds-panel { position: relative; }

/* ---------- a thing you can pick ----------
   The gold bar that wipes along the top edge of `.ctd-card--interactive`. On
   the storefront it means "this is a link"; here it means "this adds
   something", which is the same promise. It replaces nothing — the border
   still lights — and it is the one motion this section adopts, at the
   component's own 200ms, because it is a pointer response rather than
   ambience (ornament.css's motion budget).

   `overflow: hidden` so the bar stops at the radius, and `position: relative`
   so it has a box to sit in — .pos-card and .skill-card already have both. */
.v2-team-tile,
.builder-hero__pathway,
.v2-creator,
.star-add-card,
.saved-build {
  position: relative; overflow: hidden;
}
.v2-team-tile::before,
.pos-card::before,
.skill-card::before,
.builder-hero__pathway::before,
.v2-creator::before,
.star-add-card::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; z-index: 2;
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform var(--t-base) var(--ease);
  pointer-events: none;
}
.v2-team-tile:hover::before,
.v2-team-tile:focus-visible::before,
.pos-card:hover::before,
.pos-card:focus-visible::before,
.skill-card:hover::before,
.skill-card:focus-visible::before,
.builder-hero__pathway:hover::before,
.builder-hero__pathway:focus-visible::before,
.v2-creator:hover::before,
.v2-creator:focus-visible::before,
.star-add-card:hover::before,
.star-add-card:focus-visible::before { transform: scaleX(1); }
/* A card at its cap is not pickable, so it says nothing under the pointer. */
.pos-card.maxed::before { display: none; }

/* ---------- Save / Load are castings ----------
   These were flat (2026-09-05), then the small steel plate (2026-09-07), and
   they are now the site's casting at --sm — components.css owns the recipe,
   THE CASTING in tokens.css owns the argument. The pill's own border and
   radius go: the silhouette is the clip and the edge is the bevel. Load,
   while its panel is open, goes gold — the lit thing is the one that is on. */
.ctd-builderbar__actions .ctd-pill {
  --btn-c: var(--btn-cut-sm); --btn-b: var(--btn-bevel-sm);
  --btn-ci: calc(var(--btn-c) - var(--btn-b) * 0.41);
  --btn-clip: polygon(
      var(--btn-c) 0, calc(100% - var(--btn-c)) 0, 100% var(--btn-c),
      100% calc(100% - var(--btn-c)), calc(100% - var(--btn-c)) 100%,
      var(--btn-c) 100%, 0 calc(100% - var(--btn-c)), 0 var(--btn-c));
  --btn-clip-in: polygon(
      var(--btn-ci) 0, calc(100% - var(--btn-ci)) 0, 100% var(--btn-ci),
      100% calc(100% - var(--btn-ci)), calc(100% - var(--btn-ci)) 100%,
      var(--btn-ci) 100%, 0 calc(100% - var(--btn-ci)), 0 var(--btn-ci));
  --btn-rim: var(--fx-btn-rim-gun); --btn-rim-press: var(--fx-btn-rim-gun-press);
  --btn-face: var(--fx-btn-face-gun);
  position: relative; isolation: isolate;
  border: 0; border-radius: 0; background: none;
  color: var(--gold-300); text-shadow: var(--fx-btn-engrave-light);
  filter: var(--fx-btn-cast);
  transition: filter var(--t-base) var(--ease), transform var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.ctd-builderbar__actions .ctd-pill::before, .ctd-builderbar__actions .ctd-pill::after { content: ""; position: absolute; pointer-events: none; }
.ctd-builderbar__actions .ctd-pill::before { inset: 0; z-index: -2; clip-path: var(--btn-clip); background: var(--btn-rim); }
.ctd-builderbar__actions .ctd-pill::after {
  inset: var(--btn-b); z-index: -1; clip-path: var(--btn-clip-in);
  background-image: var(--btn-face); background-repeat: no-repeat;
  transition: filter var(--t-fast) var(--ease);
}
.ctd-builderbar__actions .ctd-pill > * { position: relative; z-index: 1; }
.ctd-builderbar__actions .ctd-pill:hover { background: none; border-color: transparent; color: var(--btn-hot-ink); }
.ctd-builderbar__actions .ctd-pill:hover::before { background: var(--fx-btn-rim-gold); }
.ctd-builderbar__actions .ctd-pill:hover::after { filter: brightness(1.22); }
.ctd-builderbar__actions .ctd-pill:active { transform: translateY(2px); filter: var(--fx-btn-cast-press); }
.ctd-builderbar__actions .ctd-pill:active::before { background: var(--btn-rim-press); }
.ctd-builderbar__actions .ctd-pill:active::after { filter: brightness(0.9); }
.ctd-builderbar__actions .ctd-pill:focus-visible { outline: 2px solid var(--gold-300); outline-offset: 3px; }
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"] {
  --btn-rim: var(--fx-btn-rim-gold); --btn-rim-press: var(--fx-btn-rim-gold-press); --btn-face: var(--fx-btn-face-gold);
  color: var(--on-gold); text-shadow: var(--fx-btn-engrave-dark);
}
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"]:hover { color: var(--on-gold); filter: var(--fx-btn-cast-hot); }
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"]:hover::before { background: var(--btn-rim); }
.ctd-builderbar__actions .ctd-pill[aria-expanded="true"] .ctd-i { color: var(--on-gold); }

/* ---------- the beat you are on ----------
   The chosen beat is the GOLD casting — the same one the primary button that
   moves you off it is — so the step you are on and the action that advances
   it are one material. The numeral keeps its inverted chip, an ink square on
   gold, because the numeral carries the state when the labels collapse
   under 1100. */
.v2-stepper__step.is-current {
  --btn-rim: var(--fx-btn-rim-gold); --btn-rim-press: var(--fx-btn-rim-gold-press); --btn-face: var(--fx-btn-face-gold);
  border-color: transparent; background: none; box-shadow: none;
  color: var(--on-gold); text-shadow: var(--fx-btn-engrave-dark);
}
.v2-stepper__step.is-current .v2-stepper__num {
  background: var(--ink-950); border-color: var(--ink-950); color: var(--gold-300);
  text-shadow: var(--fx-btn-engrave-light);
}

/* ---------- states, re-stated ----------
   Every rule above laid the ground on as a background-IMAGE, and each of these
   re-colours one of those elements with a `background:` shorthand earlier in
   the file — which would reset the image to none. Later in the file and same
   specificity, so these win; background-color only, so the ground stays. */
.builder-hero__pathway:hover { background-color: var(--surface-2); }
.v2-team-tile.is-selected, .v2-team-tile.is-active { background-color: var(--surface-2); }
.skill-card.selected, .skill-card.picked { background-color: var(--surface-2); }

/* ============================================================
   EFFECTS (2026-09-03) — the "after dark" layer, applied with a
   lighter hand than the storefront. The builder is a tool: nothing
   here moves, lifts or rakes. Panels get the moonlight rim, a chosen
   thing gets the gold glow, meters read as lit, and a hovered roster
   row grows a gold marker so the eye can track it down a long list.
   Every value comes from a --fx-* token (see tokens.css).
   ============================================================ */
.v2-tier, .v2-team-tile, .pos-card, .skill-card, .sprue-card,
.extra-tag-card, .v2-creator, .sprue-preset-button {
  box-shadow: var(--fx-rim);
}

/* Chosen: the border already turns gold, the glow says it out loud. */
.v2-team-tile.is-selected, .v2-team-tile.is-active {
  box-shadow: var(--fx-glow-gold);
}

/* Meters: a lit fill reads as filling up rather than as a coloured bar. */
.v2-pool-meter__fill, .progress-fill { box-shadow: var(--fx-glow-gold); }
.progress-checkpoint.reached .checkpoint-dot { box-shadow: var(--fx-glow-gold); }

/* Roster rows: a gold marker slides down the left edge under the cursor. */
.rt-row { position: relative; }
.rt-row::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
  background: var(--accent); transform: scaleY(0); transform-origin: center;
  transition: transform var(--t-base) var(--ease);
}
.rt-row:hover::before, .rt-row:focus-within::before { transform: scaleY(1); }

/* The bench and the roster sit on the pitch, not on a flat panel.
   `.extra-tag-slot.empty` used to be in this list; it is an SVG rect now and
   an empty tag WINDOW is dark moulding with a blank in it, drawn by the
   generator. A turf background on a rect was inert anyway. */
.v2-team-browser { background: var(--fx-turf), var(--bg-alt); }

@media (prefers-reduced-motion: reduce) {
  .rt-row::before { transition: none; }
}

/* ============================================================
   21 · THE BUILDER'S ASSETS ARE THE SITE'S ASSETS
   Owner, 2026-09-11: "update all the assets, such as the cards etc on the
   different builders to follow the same style as the home page / use the
   assets we made."

   WHAT §20 LEFT. That pass gave the builder's PANELS the room's materials —
   the card ground, the moonlight rim, the nameplate, the corner gusset, the
   button plate. What it did not touch is the set of things the builder draws
   that THE STOREFRONT ALSO DRAWS: a team tile, a price, a call to action.
   Each of those existed in two versions — one made of metal on the home page
   and one made of a hairline box in here — and two drawings of the same
   object is what reads as two sites, however well each one is made.

   So this section has one rule: where the builder shows a thing the home page
   also shows, it is made of the same stuff. Nothing new is invented, nothing
   is restructured, and §20's tool discipline still holds — no lift, no rake,
   no ambient motion, because the user is aiming at these rather than browsing
   them.
   ============================================================ */

/* ---------- a team tile is a stamped tag ----------
   The team step and the bench's team browser show the same thirty crests the
   home page's "Choose your team" strip shows, and there a tile IS the product:
   a pewter plate with the crest seated in a turned brass bezel (.ctd-team,
   components.css). Here it was a hairline box on --surface.

   The tag's own rolled edge replaces the border, so the border goes — a 1px
   line outside a lit edge flattens it back into an outlined box, which is the
   note .ctd-btn--primary already carries. What does NOT come across is
   .ctd-team's hover lift: the storefront browses these, the builder aims at
   them. The gold top-edge wipe §20 gave this tile is the pointer response. */
.v2-team-tile {
  border: 0;
  background-color: transparent;
  background-image: var(--fx-tag-face);
  box-shadow: var(--fx-tag-edge);
  transition: box-shadow var(--t-base) var(--ease), filter var(--t-base) var(--ease);
}
.v2-team-tile:hover { box-shadow: var(--fx-tag-edge-hover); filter: brightness(1.08); }
.v2-team-tile:active { filter: brightness(0.95); }
/* CHOSEN HAS TO SAY SO WITHOUT A BORDER TO COLOUR. It used to be
   `border-color: var(--accent)`; with the border gone the ring is drawn in the
   same shadow stack as the tag's edge, so the plate keeps its lit top and its
   contact shadow and gains a gold seat. --fx-glow-gold is §20's "this is the
   one" and rides along. */
.v2-team-tile.is-selected, .v2-team-tile.is-active {
  box-shadow: var(--fx-tag-edge), inset 0 0 0 2px var(--accent), var(--fx-glow-gold);
}
.v2-team-tile.is-pending { box-shadow: var(--fx-tag-edge), inset 0 0 0 2px var(--accent-2); }
/* THE SCRATCH TILE TAKES NO PLATE, and that is the home page's own rule rather
   than an exception: `.ctd-team--all` keeps the dashed card it always was
   because nothing is stamped on it. This tile is the other way IN, not a team,
   so it stays a dashed card here too. */
.v2-team-tile--scratch {
  background-image: var(--fx-card-ground);
  background-color: var(--surface);
  border: var(--bw) dashed var(--line-2);
  box-shadow: var(--fx-rim);
}
.v2-team-tile--scratch:hover { box-shadow: var(--fx-rim-strong); filter: none; }

/* ---------- a crest is seated in a bezel ----------
   --fx-bezel is the turned brass ring the home page seats a crest in, painted
   on the BORDER box with the disc's own fill covering everything inside it.

   IT STAYS SQUARE HERE, and that is the one deliberate difference from
   .ctd-team__crest. The home page's tile holds two LETTERS, so a circular
   bezel crops nothing; these hold the team's own logo artwork with its name
   baked into it, and a circle would take the corners off thirty wordmarks. A
   conic gradient reads as turned metal on a square frame just as well — it is
   the seat that matters, not the shape of the hole. */
.v2-team-tile__crest,
.saved-build__logo-wrap {
  border: 2px solid transparent;
  background: linear-gradient(var(--ink-900), var(--ink-900)) padding-box, var(--fx-bezel) border-box;
  box-shadow: var(--fx-bezel-edge);
}

/* ---------- a price is gold, and a total blooms ----------
   components.css gives .ctd-price__amount --fx-glow-price, so every price the
   storefront prints sits in a little gold light. The builder printed the same
   colour flat.

   IT IS THE TOTALS AND THE LADDER ONLY, not every number on the page. The
   effect budget here is half the storefront's (DESIGN-SYSTEM.md), and woo.css
   spends its share the same way: "the grand total blooms". So the live total,
   the review's total, and the price a customer is quoted BEFORE they build —
   the ladder rung and the pathway card — take the light; the dozens of small
   inline prices (capacity options, star meta rows) stay flat gold. */
.v2-tier__price,
.builder-hero__pathway-price,
.price-value,
.review-total, .total-price,
.review-price-row.total span:last-child { text-shadow: var(--fx-glow-price); }

/* ---------- the legacy .btn aliases are plates too ----------
   §17 keeps `.btn` alive because ui/saves-panel.js, ui/edit-restore.js and
   ui/team/render.js still inject it. Its `--primary` was a flat --accent
   rectangle: the button this whole site had on 2026-09-06 and replaced on
   2026-09-07, when every CTA became a bolted plate. So Resume, Suggest fill,
   Clear and the review dialog's own buttons were the last flat gold on the
   site, sitting two inches from a brass one (#btnReview) that is not.

   They take the site's CASTING at --sm (2026-09-16; components.css owns the
   recipe, THE CASTING in tokens.css the argument). Declared after §17 at
   equal specificity, which is what beats its `background` shorthand — the
   same trap the old .ctd-btn--primary:hover documented. */
.btn--primary, .btn--secondary, .btn--outline {
  --btn-c: var(--btn-cut-sm); --btn-b: var(--btn-bevel-sm);
  --btn-ci: calc(var(--btn-c) - var(--btn-b) * 0.41);
  --btn-clip: polygon(
      var(--btn-c) 0, calc(100% - var(--btn-c)) 0, 100% var(--btn-c),
      100% calc(100% - var(--btn-c)), calc(100% - var(--btn-c)) 100%,
      var(--btn-c) 100%, 0 calc(100% - var(--btn-c)), 0 var(--btn-c));
  --btn-clip-in: polygon(
      var(--btn-ci) 0, calc(100% - var(--btn-ci)) 0, 100% var(--btn-ci),
      100% calc(100% - var(--btn-ci)), calc(100% - var(--btn-ci)) 100%,
      var(--btn-ci) 100%, 0 calc(100% - var(--btn-ci)), 0 var(--btn-ci));
  --btn-rim: var(--fx-btn-rim-gun); --btn-rim-press: var(--fx-btn-rim-gun-press);
  --btn-face: var(--fx-btn-face-gun);
  position: relative; isolation: isolate;
  border: 0; border-radius: 0; background: none;
  color: var(--gold-300); text-shadow: var(--fx-btn-engrave-light);
  filter: var(--fx-btn-cast);
  transition: filter var(--t-base) var(--ease), transform var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.btn--primary::before, .btn--secondary::before, .btn--outline::before, .btn--primary::after, .btn--secondary::after, .btn--outline::after { content: ""; position: absolute; pointer-events: none; }
.btn--primary::before, .btn--secondary::before, .btn--outline::before { inset: 0; z-index: -2; clip-path: var(--btn-clip); background: var(--btn-rim); }
.btn--primary::after, .btn--secondary::after, .btn--outline::after {
  inset: var(--btn-b); z-index: -1; clip-path: var(--btn-clip-in);
  background-image: var(--btn-face); background-repeat: no-repeat;
  transition: filter var(--t-fast) var(--ease);
}
.btn--primary {
  --btn-rim: var(--fx-btn-rim-gold); --btn-rim-press: var(--fx-btn-rim-gold-press);
  --btn-face: var(--fx-btn-face-gold);
  color: var(--on-gold); text-shadow: var(--fx-btn-engrave-dark);
}
/* The casting seats — the point of the whole exercise, and the one bit of
   movement §20's budget does allow, because it is feedback and not ambience. */
.btn--secondary:hover, .btn--outline:hover { background: none; border-color: transparent; color: var(--btn-hot-ink); }
.btn--secondary:hover::before, .btn--outline:hover::before { background: var(--fx-btn-rim-gold); }
.btn--secondary:hover::after, .btn--outline:hover::after { filter: brightness(1.22); }
.btn--primary:hover { background: none; border-color: transparent; color: var(--on-gold); filter: var(--fx-btn-cast-hot); }
.btn--primary:hover::after { filter: brightness(1.08); }
.btn--secondary:active, .btn--outline:active, .btn--primary:active { transform: translateY(2px); filter: var(--fx-btn-cast-press); }
.btn--secondary:active::before, .btn--outline:active::before, .btn--primary:active::before { background: var(--btn-rim-press); }
.btn--secondary:active::after, .btn--outline:active::after, .btn--primary:active::after { filter: brightness(0.9); }
.btn--secondary:focus-visible, .btn--outline:focus-visible, .btn--primary:focus-visible { outline: 2px solid var(--gold-300); outline-offset: 3px; }

/* ---------- an intro chip is the site's chip ----------
   .ctd-chip (components.css) is an ink-ground plate in the display face; these
   were outlined pills in body type. Ground and lettering only — the padding
   and the pill radius stay, because these chips carry a whole phrase where a
   storefront chip carries a short code, and boxing them at 24px would clip the
   icon each one leads with. */
.intro-chip {
  background: var(--ink-900);
  font-family: var(--font-display); font-weight: 500;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text);
}
.intro-chip--price { color: var(--gold-300); }
.intro-chip--accent { color: var(--accent-2); }

/* ---------- the workshop mastheads are bolted in ----------
   The sprue and star pathways lead with .builder-intro, which is the biggest
   fixed panel on those screens — the Blitz pathway's equivalent
   (.builder-hero) has been gussetted since §20. The gussets are absolutely
   placed, so the panel needs a box to hang them in. */
.builder-intro { position: relative; }

/* ---------- the three pathway cards are posters ----------
   The builder's landing sells the same three routes the home page and the
   builders hub sell, and on both of those a route card is a sheet nailed up
   (js/ctd-poster.js, scrap on smoked stock). Three presentations of one choice
   is the thing this whole section exists to stop, so this is the third.

   A SHEET INSIDE A BOLTED PANEL IS NOT TIMBER ON TIMBER. The `scrap` variant
   carries no board of its own — paper and two nails, nothing else — which is
   exactly why the owner picked it ("these will go on top of the existing
   bit"). So it can hang inside .builder-hero's frame without arguing with it.

   Everything here hangs off `.is-poster`, which js/ctd-poster.js adds only
   once it has actually painted; until then the card is the card it was. The
   wrapper is a <span> because these are <button>s. */
.builder-hero__pathway.is-poster {
  background: none; border: 0; border-radius: 0; box-shadow: none; overflow: visible;
}
.builder-hero__pathway.is-poster::before { content: none; }  /* no gold wipe on paper */
.builder-hero__pathway.is-poster:hover { background-color: transparent; border-color: transparent; }
.builder-hero__pathway.is-poster > .ctd-poster__body {
  display: flex; flex-direction: column; gap: var(--s-3); text-align: left;
}

/* ---------- the Skill Builder's size ladder is a row ----------
   Five build sizes stacked in a column, 360px tall, with the Quick fills
   toggle stranded beside them — while the Blitz builder's equivalent ladder
   (the price rungs on its team step, and the +6/+12/+18 steps on its progress
   step) is a row in both places.

   This is the SECOND half of a bug the 2026-09-08 declunk only half-fixed.
   `.sprue-capacity-selector` was one class shared by two surfaces that want
   different layouts; that pass gave the progress step its own
   (`.v2-tagstep-selector`, a segmented row) and left this one, the original,
   as the column it had always been. A size ladder is a row of peers wherever
   else this app draws one. */
.sprue-capacity-selector { flex-direction: row; flex-wrap: wrap; }

/* ============================================================
   22 · THE BAR SITS RIGHT, AND THE BUILDER HAS A MATERIAL
   Owner, 2026-09-12: "the header bar in the section doesn't work well, it
   goes past the window and also falls under the top bar. We want textures
   etc across the whole builder."

   Three separate faults and one direction, in that order below.
   ============================================================ */

/* ---------- 1. THE BAR WENT UNDER THE HEADER ----------
   `.ctd-header` is `position: sticky; z-index: var(--z-header)` (components.css
   :171). §1 of this file unpins it on app pages — `position: relative`, so it
   scrolls away and the builder bar is the chrome that stays — but it left the
   z-index behind. A positioned element with a z-index is a stacking context at
   100, and the sticky bar is 90, so for the ~88px of scroll it takes the header
   to clear the top of the window the bar slid UNDERNEATH it: the timber facia
   and the overhanging badge painted across the title and the wizard beats.

   THE HEADER CANNOT SIMPLY BE DEMOTED, which is the part worth writing down.
   Its popovers — the Builders nav panel and the mini-cart — hang DOWN from it
   at `top: calc(100% + var(--s-2))`, which is exactly where this bar is, and
   they are sealed inside THREE nested stacking contexts on the way out
   (`.ctd-header` 100 > `.ctd-header__inner` 2 > `.ctd-nav` 2). Their own
   `z-index: var(--z-header)` is therefore relative to `.ctd-nav`, not to the
   page: the only reason a dropdown clears this bar today is that its ANCESTOR
   outranks it. Set the header to `auto` and the scroll overlap goes away and
   takes the Builders menu with it, behind the bar. Neutralising the inner
   contexts instead is worse again — `.ctd-nav`'s z-index is what keeps the nav
   plates above the header's own boarding and flange.

   So the header keeps its context and just stops out-ranking the bar: at an
   equal z-index the later element in the DOM paints on top, and the bar comes
   after the header. Then hover or focus anywhere inside the header — the only
   state in which any of its popovers can be open — lifts it back over the bar
   for as long as that lasts. No stacking-context surgery, and each of the two
   overlaps wins at the moment it should. */
body.ctd-app-page .ctd-header { z-index: var(--z-sticky); }
body.ctd-app-page .ctd-header:hover,
body.ctd-app-page .ctd-header:focus-within { z-index: calc(var(--z-sticky) + 2); }

/* ---------- 2. THE BAR RAN PAST THE FRAME ----------
   Everything else on this page stops at the steel: the nav facia ends on the
   girders' outer faces (chrome.css, 2026-09-08), a section's ground stops at
   --bay-gutter, and the content sits inside --wrap. The builder bar was a
   full-bleed band painted on the element itself, so above the girder gate it
   ran out over both frame columns and off the window — the one thing on the
   page that ignored the building it is in.

   The ground moves to a ::before inset by --bay-gutter, which is the same
   device `.ctd-section--alt::before` uses and for the same reason: a
   background on the element is the width of the page and cannot be told where
   to stop. --bay-gutter is 0 below 1360 where there is no frame, so nothing
   changes at the widths that never had girders.

   The border and the cast go with it — a hairline the full width of the screen
   under a band that stops 88px short is the same mistake in one pixel. */
.ctd-builderbar {
  background: none;
  border-bottom: 0;
  box-shadow: none;
  isolation: isolate;      /* the ::before sits behind the row, not behind the page */
}
.ctd-builderbar::before {
  content: ""; position: absolute; inset: 0;
  left: var(--bay-gutter); right: var(--bay-gutter);
  z-index: -1; pointer-events: none;
  background-color: var(--bg-alt);
  /* A SHELF WITH A SURFACE. The grain is the same pair base.css lays over the
     whole document, so the bar is made of the page's own material rather than
     of a flat tint; --fx-channel seats it into the page like the steel channel
     the nav boards sit in. */
  background-image: var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  background-size: 3px 3px, 7px 7px, auto;
  border-bottom: var(--bw) solid var(--line-2);
  box-shadow: var(--fx-channel);
}

/* ---------- 3. TEXTURES ACROSS THE WHOLE BUILDER ----------
   Owner: "the cards/backgrounds could be much better ... we want textures etc
   across the whole builder." Two moves, and both spend materials this site
   already has rather than inventing a builder-only one.

   THE FLOOR COMES BACK UP. §1 turned the generated paving down to 0.3 — half
   the storefront's — on the argument that the builder is a tool and the floor
   owes it depth and nothing else. That was the right call for a page of flat
   panels and the wrong one for this brief: with every panel tinted --surface
   and the ground almost off, the whole app was one colour. 0.62 is the site's
   own value (tokens.css), so the builder now stands on the same floor as every
   other page rather than on a dimmed copy of it. */
body { --ground-amp: 0.62; }

/* A PANEL IS BOARDED. The five big fixed panels — the landing hero, the two
   workshop mastheads, the bench and the saved-builds tray — are the builder's
   equivalent of a storefront section, and the storefront lines a section with
   timber (`.ctd-section--wood`, index.php's "How Blitz Bases work"). They are
   already bolted in at the corners; boarding is what those gussets are bolted
   THROUGH.

   Not the working cards, and not the roster: a base sprue is a drawing that
   has to be read, and sixteen of them over a wood grain is the one place this
   would cost legibility. The cards sit ON the boarding instead, which is the
   arrangement the home page uses and the reason a card has a ground at all. */
.ctd-boarded { position: relative; }
/* .ctd-planks is written for a full-bleed SECTION, so it insets itself by
   --bay-gutter — 88px of a panel that is only 300px wide, above the gate.
   Inside a box it fills the box. */
.ctd-boarded > .ctd-planks { left: 0; right: 0; border-radius: inherit; }
/* Everything that is not a LAYER sits above the boarding, exactly as
   `.ctd-section--wood > :not(.ctd-planks)` does on the storefront.

   `.ctd-orn` HAS TO BE EXCLUDED TOO, and leaving it out collapsed the bench.
   The gussets are `position: absolute` with a z-index of their own; this
   selector is (0,2,0) against `.ctd-orn`'s (0,1,0), so it won outright and
   turned four absolutely-placed corners into four relative blocks IN THE FLOW
   — a column of bronze brackets down the left edge with the panel's real
   content pushed below them. It is the identical trap components.css records
   for `.ctd-turf` under `.ctd-section--alt`, and for the identical reason: a
   layer that is positioned by the component must never be re-positioned by the
   container. Every absolutely-placed child of a boarded panel belongs in this
   exclusion list.

   `.v2-bench-browser` IS THE THIRD ONE, and it cost the "Adding from" switch.
   ui/team/index.js appends the team popover to the bench panel at runtime, so
   it never appeared in the template to be noticed here; this selector is (0,3,0)
   against its (0,1,0), so the popover lost `position: absolute` and its z-index
   of 6, dropped into the flow under the card strip with `top` re-read as a
   487px relative nudge, and the panel — which clips (see §6) — swallowed it.
   Clicking the tile did everything but show the list. */
.ctd-boarded > :not(.ctd-planks):not(.ctd-orn):not(.v2-bench-browser) { position: relative; z-index: 1; }
/* The timber is a BACKING, not the subject: a panel here carries body copy,
   pathway cards and a scrolling bench, where a storefront wood band carries a
   heading and four photos. Half amplitude keeps the boards reading as boards
   without the grain arguing with the type on top of them. */
.ctd-boarded { --plank-amp: 0.52; }
/* The panel's own tint would sit BETWEEN the boards and the content and hide
   them, so a boarded panel gives its ground up to the timber and keeps only
   the lit top edge and its border. */
.builder-hero.ctd-boarded,
.builder-intro.ctd-boarded,
.saved-builds-panel.ctd-boarded,
.positional-picker-area.ctd-boarded {
  background-color: transparent;
  background-image: none;
}

/* A CARD HAS A SURFACE. Every working card was `--surface` plus the
   --fx-card-ground wash, which is a lit rectangle rather than a material. The
   same grain the document and the bar carry goes over the wash, so a card is
   made of something at the distance you actually read it from.

   The team tile and the pathway card are NOT in this list on purpose: one is a
   stamped pewter tag and the other is a sheet of paper (§21), and both already
   carry a material of their own. */
.v2-format-panel,
.v2-extras,
.v2-tier,
.pos-card,
.roster-player-card,
.extra-tag-card,
.skill-card,
.sprue-card,
.star-add-card,
.star-player-card,
.saved-build,
.paint-panel,
.v2-bench-source,
.custom-positional-modal__frame-pane,
.sprue-frame-panel,
.sprue-preset-tray,
.sprue-skill-catalogue {
  background-image: var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  background-size: 3px 3px, 7px 7px, auto;
}

/* The header strips inside those cards are the one place a second material
   reads as construction rather than as noise: a lid on a box. */
.extra-tag-card__header,
.sprue-card-header, .sprue-card-head,
.sprue-frame-panel__head,
.sprue-preset-tray__head,
.sprue-skill-catalogue__head,
.saved-builds-panel__header,
.review-team-banner { box-shadow: var(--fx-channel); }

/* ============================================================
   23 · THE BAR IS A BOLTED MEMBER, AND THE PARTS BIN IS MADE OF PARTS
   Owner, 2026-09-12: "the backgrounds to the positionals/the sprues etc are
   all bland, and the header itself for the blitz builder needs a rework."

   §22 fixed where the bar SITS. This is what it is made of, and what the
   things you pick and the things you are shown are made of.
   ============================================================ */

/* ---------- the bar is a cross member ----------
   It was a flat tinted band directly under the most made thing on the site —
   a timber facia in a steel channel with a painted badge hung off it — and a
   tinted band is what every panel on this page used to be.

   A MEMBER, NOT A SECOND RACK OF NAV PLATES. The 2026-09-05 call still holds:
   borrowing the header's riveted timber would read as two headers 60px apart.
   A steel cross member is the other thing this building is made of, it is what
   closes a bay top and bottom everywhere else on the site, and it is exactly
   what a shelf under the header is. The recipe is the home page's trust strip,
   which is the same object — a band held between two members gets the rivets
   that hold it there. */
body.ctd-app-page .ctd-builderbar::before {
  /* A CHANNEL, NOT A FACE. Two versions were tried before this one. Rivet
     seams along both flanges (the .ctd-home__trust recipe) read as scattered
     dots at 56px tall rather than as a bolted flange — that band is 100px and
     full-bleed, and the device does not survive the size. A steel FACE
     (--fx-btn-steel across the bar) swallowed Save and Load, which are cut
     from that same steel.

     So the bar is the RECESS and the fittings in it carry the metal — which
     is the nav's own arrangement one floor up: timber boards seated in a steel
     channel, not a steel bar with timber painted on it. Near-black, grained
     like the document, sunk with --fx-channel, and everything standing in it
     (the bronze nameplate, the steel beats, the brass current step, Save and
     Load) now reads as a fitting bolted into a dark slot. */
  background-color: var(--ink-950);
  background-image: var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  background-size: 3px 3px, 7px 7px, auto;
}

/* ---------- the tool wears its name on a plate ----------
   The title was a bare uppercase span. Everything else on this site that NAMES
   something is lettered on bronze — every section heading, both workshop
   mastheads, the team step. The builder's own name had no reason to be the
   exception, and on a steel member a bronze nameplate is the one fitting that
   belongs there (ornament.css: "IT IS BOLTED ACROSS THE MEMBER").

   It is the REAL component, not a copy: `.ctd-builderbar__id` was already a
   wrapper div around the title, so it becomes the plate and the span becomes
   its title — four rivets added in the markup and nothing else. Sized down
   from the section plaque, because a 56px bar is not a section: every number
   below comes off --plate-rivet-r, exactly as the component intends. */
.ctd-builderbar__id.ctd-plate {
  --plate-rivet-r: 3px;
  --plate-min-w: 0;
  --plate-fs: var(--fs-15);
  flex: none;
  align-self: center;
  margin-inline: 0;                 /* left-aligned on the bar, not centred */
  /* THE PADDING IS SET BY THE HARDWARE, not by taste — the component's own
     rule. --plate-frame (the field cut into the face) is
     inset + r + 2 = 11px here, and --plate-rivet-x puts a dome 10.2px in from
     each end. Pad any tighter and the field's keyline and the two end rivets
     land ON the lettering, which is exactly what 16px did: "BLITZ BUILDER"
     came out with a rivet through the B and the R. --s-6 (24px) clears both. */
  padding: 7px var(--s-6);
}
.ctd-builderbar__id.ctd-plate .ctd-plate__title {
  font-size: var(--plate-fs);
  white-space: nowrap;
}

/* ---------- the wizard beats are castings on the member ----------
   Four hairline outlines and one plate became five plates on 2026-09-08, and
   five castings on 2026-09-16 when every control on the site did: the small
   casting, gunmetal, with the current beat in gold (above). The base rule's
   flat hover fill has to be turned off here at its own specificity, or a
   surface-coloured rectangle paints behind the clipped face. */
.v2-stepper__step {
  --btn-c: var(--btn-cut-sm); --btn-b: var(--btn-bevel-sm);
  --btn-ci: calc(var(--btn-c) - var(--btn-b) * 0.41);
  --btn-clip: polygon(
      var(--btn-c) 0, calc(100% - var(--btn-c)) 0, 100% var(--btn-c),
      100% calc(100% - var(--btn-c)), calc(100% - var(--btn-c)) 100%,
      var(--btn-c) 100%, 0 calc(100% - var(--btn-c)), 0 var(--btn-c));
  --btn-clip-in: polygon(
      var(--btn-ci) 0, calc(100% - var(--btn-ci)) 0, 100% var(--btn-ci),
      100% calc(100% - var(--btn-ci)), calc(100% - var(--btn-ci)) 100%,
      var(--btn-ci) 100%, 0 calc(100% - var(--btn-ci)), 0 var(--btn-ci));
  --btn-rim: var(--fx-btn-rim-gun); --btn-rim-press: var(--fx-btn-rim-gun-press);
  --btn-face: var(--fx-btn-face-gun);
  position: relative; isolation: isolate;
  border-color: transparent; border-radius: 0; background: none;
  color: var(--bone-300); text-shadow: var(--fx-btn-engrave-light);
  filter: var(--fx-btn-cast);
  transition: filter var(--t-base) var(--ease), transform var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.v2-stepper__step::before, .v2-stepper__step::after { content: ""; position: absolute; pointer-events: none; }
.v2-stepper__step::before { inset: 0; z-index: -2; clip-path: var(--btn-clip); background: var(--btn-rim); }
.v2-stepper__step::after { inset: var(--btn-b); z-index: -1; clip-path: var(--btn-clip-in); background-image: var(--btn-face); background-repeat: no-repeat; transition: filter var(--t-fast) var(--ease); }
.v2-stepper__step > * { position: relative; z-index: 1; }
.v2-stepper__step .ctd-i { color: var(--ok); }
.v2-stepper__step:not(:disabled):hover { background: none; border-color: transparent; color: var(--btn-hot-ink); }
.v2-stepper__step:not(:disabled):hover::before { background: var(--fx-btn-rim-gold); }
.v2-stepper__step:not(:disabled):hover::after { filter: brightness(1.22); }
.v2-stepper__step.is-current:not(:disabled):hover { color: var(--on-gold); filter: var(--fx-btn-cast-hot); }
.v2-stepper__step.is-current:not(:disabled):hover::before { background: var(--btn-rim); }
.v2-stepper__step.is-current:not(:disabled):hover::after { filter: brightness(1.08); }
.v2-stepper__step:not(:disabled):active { transform: translateY(2px); filter: var(--fx-btn-cast-press); }
.v2-stepper__step:not(:disabled):active::before { background: var(--btn-rim-press); }
/* A beat you have finished reads brighter than one you have not reached. */
.v2-stepper__step.is-done { color: var(--bone-100); }

/* ---------- a positional is a stamped tag ----------
   The bench is timber now (§22) and the cards on it were flat tinted
   rectangles. A positional is a PART you press to add — the same act, and the
   same kind of object, as the team tile §21 made a pewter plate. So the bench
   reads as metal tags laid out on a wooden bench, which is what a parts bin
   looks like and what this product actually is.

   The creator card is excluded, and that is the same rule the scratch team
   tile and `.ctd-team--all` follow: nothing is stamped on it, it is the way to
   make a NEW part rather than a part, so it keeps its dashed outline. */
.pos-card:not(.custom-pos-card--empty) {
  border: 0;
  background-color: transparent;
  background-image: var(--fx-tag-face);
  box-shadow: var(--fx-tag-edge);
  transition: box-shadow var(--t-base) var(--ease), filter var(--t-base) var(--ease);
}
.pos-card:not(.custom-pos-card--empty):hover {
  box-shadow: var(--fx-tag-edge-hover); filter: brightness(1.08);
}
.pos-card:not(.custom-pos-card--empty):active { filter: brightness(0.95); }
/* The states that used to colour a border now seat a ring in the same shadow
   stack, so the plate keeps its lit top and its contact shadow — the device
   §21 worked out for the team tile. */
.pos-card.is-full:not(.custom-pos-card--empty) {
  box-shadow: var(--fx-tag-edge), inset 0 0 0 2px var(--ok);
}
/* NO ring for `.has-qty`. It was added here and taken straight back out: a
   typical bench has every positional at qty>0, so a gold seat on "you have
   some of these" lit the whole strip and said nothing. The QTY BADGE is where
   that count belongs, and it already carries it. Only `.is-full` rings — that
   is a limit, which is news. */
/* At its cap it is not pickable, so it stops answering the pointer. The COUNT
   already carries the state in --warn (§6); the plate just goes quiet. */
.pos-card.maxed:not(.custom-pos-card--empty):hover {
  box-shadow: var(--fx-tag-edge); filter: none;
}

/* ---------- a skill card is a part too, lightly ----------
   Seventy-two of these tile the skills step, so they get the raised edge
   rather than the full pewter face: at that count a light plate becomes a
   sheet of light and the category hues stop reading. The left border stays
   exactly as it was — it is the category carrier and body.cb-mode's only
   visible channel (DESIGN-SYSTEM.md §7). */
.skill-card { box-shadow: var(--fx-tag-edge); }
.skill-card:hover { box-shadow: var(--fx-tag-edge-hover); }
.skill-card.selected, .skill-card.picked, .skill-card.has-qty {
  box-shadow: var(--fx-tag-edge), var(--fx-glow-gold);
}

/* ---------- a drawn sprue sits in a tray ----------
   Every frame this app draws — the roster base, the progress sheets, the sprue
   builder's sheet, the star's frame, the review's proofs — is a near-black
   moulding, and it was sitting on a mid-tone tinted card. A part is not
   presented on a card; it comes in a well, and the well is darker than the
   thing in it. --fx-channel is the recess the nav boards already sit in.

   NO PADDING ON `.rpc-sprue`, and this is load-bearing rather than tidy:
   `.rpc-fields` is `position: absolute; inset: 0` on it and mounts the rename
   control at percentages of the drawing's viewBox. Padding moves the SVG
   inside the content box while the field layer still covers the padding box,
   so every mounted control would drift by the padding. The channel's inset
   shadow reads as the tray's lip over the drawing's own edge, which is what a
   part in a tray looks like anyway. */
.rpc-sprue,
.sprue-frame,
.extra-tag-card__body,
.review-frames,
.review-sheets,
.spc-sprue-frame,
.v2-draft-scroll {
  background-color: var(--ink-950);
  background-image: var(--fx-tooth), var(--fx-tooth-2);
  background-size: 3px 3px, 7px 7px;
  box-shadow: var(--fx-channel);
  border-radius: var(--r-1);
}
/* The review's proofs are already inside a dialog's own well, so they take the
   tray without a second border around each frame. */
.review-frame svg, .rpc-sprue__frame > svg { border-radius: var(--r-1); }

/* ============================================================
   24 · THE ROSTER CARD IS A TRAY WITH A LABEL, NOT A TINTED BOX
   Owner, 2026-09-12, pointing at a roster card: "this needs more styling to
   suit the theme, it's just blank purple currently."

   AND IT WAS BLANK BECAUSE OF THE MARGIN, not the colour. §23 put the drawn
   sprue in a dark tray, which was right, but the card kept 12px/16px of
   padding around it — so every card was a ring of flat --surface framing a
   well, and the biggest single area on the busiest screen in the app was
   tinted nothing. Sixteen of those is what reads as purple.

   A storefront card does not do this either: `.ctd-card__media` runs edge to
   edge and the copy sits under it. The part IS this card's media. So the tray
   goes full bleed, the identity line becomes a head strip above it, and the
   footnote a foot strip below — the same three-part card the rest of the site
   is built from, with the drawing where the photo goes.
   ============================================================ */
.roster-player-card--sprue {
  padding: 0;
  gap: 0;
  overflow: hidden;
  /* A card holding a part is a physical thing: the raised edge the bench tags
     and the skill cards carry (§23), so it sits ON the boarding rather than
     being a shape cut out of it. */
  box-shadow: var(--fx-tag-edge);
}
.roster-player-card--sprue:hover { box-shadow: var(--fx-tag-edge-hover); }

/* The identity line is the label on the tray: number, name, value, bin. It
   gets its own ground and a seated edge so it reads as a strip fixed to the
   card rather than as text floating on a tint. */
.roster-player-card--sprue > .rpc-header {
  margin: 0;
  padding: var(--s-2) var(--s-3);
  background-color: var(--bg-alt);
  background-image: var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  background-size: 3px 3px, 7px 7px, auto;
  border-bottom: var(--bw) solid var(--line-1);
  box-shadow: var(--fx-channel);
}

/* THE TRAY NOW FILLS THE CARD. Corners square off against the card's own
   radius (the card clips them) and the margin goes, so there is no tinted
   gutter left anywhere on the card.

   STILL NO PADDING ON `.rpc-sprue` — the §23 note holds and is the reason
   this is done with margin and radius rather than by insetting the tray:
   `.rpc-fields` is `inset: 0` on it and mounts the rename control at
   percentages of the drawing's viewBox, so any padding here would drift every
   mounted control off its window. */
.roster-player-card--sprue > .rpc-sprue {
  /* A SEAT, NOT A FULL BLEED, and the 4px is measured rather than chosen. The
     rename pencil hangs 70% of its own disc off the nameplate window's right
     edge (sprue-fields.js, and the 70% is itself a measured number — at 92%
     it covered the R of THROWER). The nameplate sits near the drawing's right
     edge, so with the tray flush to the card the badge landed ON the border
     and `overflow: hidden` took a pixel off it. 4px of seat clears the disc
     and still reads as the tray's rim rather than as the 16px gutter of tinted
     nothing this section removed.

     MARGIN, NEVER PADDING. `.rpc-fields` is `inset: 0` on this element and
     mounts every control at percentages of the drawing's viewBox: padding
     would move the SVG inside the content box while the field layer still
     covered the padding box, drifting all of them. Margin moves the drawing
     and the field layer together, so they stay registered. */
  margin: 0 var(--s-1) var(--s-1);
  border-radius: var(--r-1);
}

/* The footnote ("+2 tags print on the progress sprue") and the 40mm edit-tags
   button are the foot of the card, so they take a foot's padding and rule
   rather than sitting hard against the tray. */
.roster-player-card--sprue > .rpc-sprue__overflow,
.roster-player-card--sprue > .rpc-sprue__foot {
  padding: var(--s-2) var(--s-3);
  border-top: var(--bw) solid var(--line-1);
}

/* An empty slot is the same object with nothing written on it, so it keeps the
   same construction — but it must not also gain the raised edge, or sixteen
   ghosts would stand as proud as the players on the roster. */
.roster-player-card--sprue.roster-player-card--empty { box-shadow: var(--fx-rim); }
.roster-player-card--sprue.roster-player-card--empty:hover { box-shadow: var(--fx-rim); }

/* ---- §24.1 THE TRAY IS MADE OF SOMETHING (owner, 2026-09-13: "the cards etc
   are too bland / don't match the rest of the styling we have done on the
   site. they are just blank purple") ----

   §24 ABOVE ANSWERED THE SAME COMPLAINT AND ONLY HALF OF IT. It was right
   about the cause it found — 16px of flat --surface ringing every card — and
   full-bleeding the tray removed the purple. What it did not do is give the
   card a MATERIAL: the head strip was --bg-alt plus grain, the edge was a
   generic raised rim, and the number was the one pure-white object on the
   page. Structure, no substance. Hence the second complaint.

   So this layer is hardware only, and it changes no geometry: the full-bleed
   tray, the 4px seat and the head/foot strips all stay exactly as §24 set
   them, including the rule that `.rpc-sprue` may NEVER take padding.

   WHERE THE HARDWARE CAN GO. The drawing is full-bleed, so there is no flank
   left to rivet — bolts down the card's edges would land on the part. The
   label strip is the one piece of chrome the card still owns, so that is what
   becomes the fitting: a plate bolted across the top of the tray, with the bay
   tag riveted to it. Everything else is the pressing it is bolted to.

   BIMETAL, BODY COLD, HARDWARE WARM. The site's own rule
   (bimetal-frame-and-bolt-alloys): a brass web with the contrast in the studs.
   Sixteen brass trays would be a gold page with a drawing lost in the middle
   of each one, so the tray stays in the plum/iron family and every warm note
   is a fitting. */
.roster-player-card--sprue {
  position: relative;
  background-image: var(--fx-tray), var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  /* Pressed sheet, not a floating panel: a hairline crown, the depth of the
     pressing under it, and a short throw. See --fx-tray-edge. */
  box-shadow: var(--fx-tray-edge);
  transition: box-shadow var(--t-base) var(--ease);
}
/* Picked up and turned towards the light — the same fitting, brighter rim and
   a longer throw. NO TRANSFORM: sixteen cards nudging under the pointer is
   motion for its own sake, and a rim catching the light is what the object
   would actually do. */
.roster-player-card--sprue:hover { box-shadow: var(--fx-tray-edge-lit); }

/* ---- the part is SEATED, not laid on ----
   With the tray lit, the drawing needs a recess to sit down inside or it looks
   stuck to the front. A seat all round (§24 gave it three sides; the fourth
   just shows the same metal above the part) plus the well's own shadow.

   MARGIN AND BOX-SHADOW ONLY — NEVER PADDING. §24's note is load-bearing:
   `.rpc-fields` is `inset: 0` on this element and mounts every control at
   percentages of the drawing's viewBox, so padding would move the SVG inside
   the content box while the field layer still covered the padding box and
   drift the rename pencil off its window. Margin moves both together. */
.roster-player-card--sprue > .rpc-sprue {
  margin: var(--s-1) var(--s-1) var(--s-1);
  box-shadow: var(--fx-tray-well);
}

/* ---- the label plate ----
   §24 made this a strip with a ground; this makes it a plate bolted on. The
   bevel is the tray's own metal in the tray's own light, so the plate and the
   pressing read as one fitting rather than two materials stacked. */
.roster-player-card--sprue > .rpc-header {
  background-image: var(--fx-tray-lip), var(--fx-tooth), var(--fx-tooth-2);
  background-size: auto, 3px 3px, 7px 7px;
  border-bottom: 0;
  box-shadow: var(--fx-tray-lip-edge);
}
/* THE TWO BOLTS THAT HOLD IT ON. One at each end of the plate, which is where
   a plate is actually fixed — and only two, because four on a strip this
   shallow reads as a row of dots rather than as fixings. --fx-rivet is the
   site's bronze bolt at its own scale; 7px of inset puts each one in the
   strip's own margin rather than under the number or the bin.

   POINTER-EVENTS OFF: the strip carries the rename target and the delete
   button, and a decorative dome must not eat either. */
.roster-player-card--sprue > .rpc-header::before,
.roster-player-card--sprue > .rpc-header::after {
  content: ""; position: absolute; top: 50%; width: 9px; height: 9px;
  margin-top: -4.5px;
  background-image: var(--fx-rivet);
  background-repeat: no-repeat; background-position: center;
  pointer-events: none;
}
.roster-player-card--sprue > .rpc-header::before { left: 5px; }
.roster-player-card--sprue > .rpc-header::after { right: 5px; }
/* The plate's own text is cast into it, not printed on it. */
.roster-player-card--sprue > .rpc-header { position: relative; }
.roster-player-card--sprue .rpc-position { text-shadow: var(--fx-plate-stamp); }

/* ---- the bay tag ----
   The number was a bone square: the flattest object on the card and the only
   pure white on the screen. It is a brass tag now, at the smallest size that
   still resolves as hardware — below about 8px a lit dome stops reading as
   metal and reads as a dot, which is the note --fx-seam-rivets carries and the
   reason .ctd-seam was killed. */
.roster-player-card--sprue .rpc-number {
  width: 30px; height: 26px;
  border-radius: 3px;
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 55%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--joint-lo) 90%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
  color: var(--ink-950);
  font-size: var(--fs-14);
  text-shadow: var(--fx-tag-emboss);
}
/* A BIG GUY'S TAG IS A DIFFERENT ALLOY, NOT A BRIGHTER ONE — the rule the
   badge settled (three-metals-and-verdigris): the secondary metal is a second
   material, with the green that only grows where two of them meet. */
.roster-player-card--sprue.big-guy-row .rpc-number {
  background-color: var(--joint-patina);
  box-shadow:
    inset 0 0 0 1px var(--verd-seat),
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 38%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
}

/* ---- an empty bay ----
   Dimming the whole card dimmed the drawn frame inside it, which is the one
   thing an empty slot has to say: THIS is the part you have not chosen yet. So
   the knock-back moves off the card and onto the tray, and the card itself
   becomes the same pressing with nothing in it — unlit, and wearing the
   diagonal caution hatch a workshop paints on a space meant to be filled.

   The hatch is two thin strokes at 3%. A real hazard stripe at real contrast
   would shout louder than the players who ARE on the roster, which is exactly
   backwards. */
.roster-player-card--sprue.roster-player-card--empty {
  opacity: 1;
  border-color: color-mix(in srgb, var(--line-1) 70%, transparent);
  background-image: var(--fx-tray-vacant), var(--fx-tooth), var(--fx-tooth-2);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--iron-spec) 14%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--ink-950) 70%, transparent);
}
.roster-player-card--sprue.roster-player-card--empty:hover {
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--iron-spec) 14%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--ink-950) 70%, transparent);
}
/* An unfilled bay is not bolted shut. */
.roster-player-card--sprue.roster-player-card--empty > .rpc-header::before,
.roster-player-card--sprue.roster-player-card--empty > .rpc-header::after {
  background-image: var(--fx-bolt-hole);
}
.roster-player-card--sprue.roster-player-card--empty > .rpc-header {
  background-image: none;
  box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ink-950) 55%, transparent);
}
/* The bay number is stamped INTO the empty tray — debossed, with no tag under
   it, because there is no part here to label. */
.roster-player-card--sprue.roster-player-card--empty .rpc-slot-number {
  font-family: var(--font-display); font-weight: 700;
  font-size: var(--fs-16); font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--bone-100) 26%, transparent);
  text-shadow: var(--fx-bay-deboss);
}
/* The slots past the smallest set we sell stay fainter — on the TRAY's
   contents, not on the pressing, so the row still reads as a rack of bays. */
.roster-player-card--sprue.roster-player-card--optional { opacity: 1; }
.roster-player-card--sprue.roster-player-card--optional > .rpc-sprue,
.roster-player-card--sprue.roster-player-card--optional > .rpc-header { opacity: 0.5; }

/* ---- §24.2 THE RENAME IS A CONTROL ON THE STRIP (owner, 2026-09-14: "remove
   the edit from the skill, and make it more obvious on the header bar") ----

   The label was a span with `cursor: text` and a data-tooltip. That is a
   control you can only discover by pointing at it, on the one thing a customer
   can change for free — so most never found it, and the ones who did found it
   on the DRAWING instead, where the pencil used to be.

   It is a button now, with the name, a pencil badge and a field's own
   underline. The underline is dotted rather than solid: solid reads as a link,
   and this edits in place. */
.roster-player-card--sprue .rpc-position.js-player-rename {
  display: inline-flex; align-items: center; gap: 5px;
  min-width: 0;
  padding: 1px 4px;
  margin-left: -4px;             /* the padding must not shift the strip */
  border: 0; border-radius: var(--r-1);
  background: transparent;
  color: var(--text);
  text-shadow: var(--fx-plate-stamp);
  cursor: text;
  transition: background-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}
.roster-player-card--sprue .rpc-position__text {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  border-bottom: 1px dotted color-mix(in srgb, var(--bone-100) 34%, transparent);
}
/* The badge is the thing that says "changeable" without being hovered, so it
   is always visible — faint at rest, full under the pointer. A hover-only
   affordance is what this section is replacing. */
.roster-player-card--sprue .rpc-rename-badge {
  display: inline-flex; flex: none;
  color: var(--accent);
  opacity: 0.72;
  transition: opacity var(--t-fast) var(--ease);
}
.roster-player-card--sprue .rpc-position.js-player-rename:hover,
.roster-player-card--sprue .rpc-position.js-player-rename:focus-visible {
  background-color: color-mix(in srgb, var(--ink-950) 42%, transparent);
  box-shadow: inset 0 0 0 var(--bw) color-mix(in srgb, var(--accent) 60%, transparent);
  outline: none;
}
.roster-player-card--sprue .rpc-position.js-player-rename:hover .rpc-rename-badge,
.roster-player-card--sprue .rpc-position.js-player-rename:focus-visible .rpc-rename-badge { opacity: 1; }
/* A player who HAS been renamed keeps the badge but drops the dotted rule —
   the underline invites a first edit; after that the name is the answer. */
.roster-player-card--sprue .rpc-position.is-renamed .rpc-position__text { border-bottom-color: transparent; }

/* ---- §24.3 THE BENCH CARD IS A STOCK TICKET (owner, 2026-09-14: "the
   positional cards need work now") ----

   The roster trays got their material and left the bench behind, which made
   the bench the blandest thing on the step: flat --surface panels with a
   hairline, sitting on boarding, feeding a rack of pewter trays.

   SAME KIT, SMALLER, AND A DIFFERENT JOB. A tray holds a part you have bought;
   a bench card is the ticket you pick it from — so it takes the tray's face
   and its brass fitting, but not its lip or its well, because there is nothing
   seated in it. It is the stock card on the rack, not the rack.

   THE QUANTITY IS THE FITTING, and it costs nothing to say so: the bench
   already reuses `.rpc-number` for its "6/16", so the roster's bay tag and
   this are the same element wearing the same brass. That is the whole reason
   the two regions will read as one kit. */
.positionals-grid .pos-card:not(.custom-pos-card--empty) {
  background-image: var(--fx-tray), var(--fx-tooth), var(--fx-tooth-2), var(--fx-card-ground);
  box-shadow: var(--fx-tray-edge);
  transition: box-shadow var(--t-base) var(--ease), border-color var(--t-fast) var(--ease);
}
.positionals-grid .pos-card:not(.custom-pos-card--empty):hover,
.positionals-grid .pos-card:not(.custom-pos-card--empty):focus-visible,

/* The count, in the same brass as a bay tag. Wider than the roster's because
   it carries "12/16" rather than "7", and the numerals are tabular so the
   column does not jitter as a roster fills. */
.positionals-grid .pos-card:not(.custom-pos-card--empty) .pos-card-qty {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 38px; padding: 1px 5px;
  border-radius: 3px;
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 55%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--joint-lo) 90%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
  color: var(--ink-950);
  font-variant-numeric: tabular-nums;
  text-shadow: var(--fx-tag-emboss);
}
/* AT ITS CAP THE TICKET IS SPENT. The brass goes to patina and the card stops
   claiming to be pickable — it already drops the hover sweep (.pos-card.maxed
   ::before), and a bright tag over a dead card was the last thing still
   saying "press me". */
.positionals-grid .pos-card:not(.custom-pos-card--empty).maxed .pos-card-qty {
  background-color: var(--joint-patina);
  box-shadow:
    inset 0 0 0 1px var(--verd-seat),
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 30%, transparent);
}
/* The name is stamped, like every other name on this kit. */
.positionals-grid .pos-card:not(.custom-pos-card--empty) .pos-card-name { text-shadow: var(--fx-plate-stamp); }

/* The category rail was a 3px flat bar. It is the ticket's punched edge now:
   the same rail, lit on one side and shadowed on the other, so it reads as a
   fold in the card rather than as a line drawn on it. */
.positionals-grid .pos-card:not(.custom-pos-card--empty) .pos-card-stripe {
  width: 4px;
  box-shadow:
    inset -1px 0 0 color-mix(in srgb, var(--ink-950) 70%, transparent),
    inset 1px 0 0 color-mix(in srgb, var(--bone-100) 20%, transparent);
}

/* A 40mm ticket is a different alloy, matching its tray's bay tag. */
.positionals-grid .pos-card:not(.custom-pos-card--empty)[data-is-big-guy="1"] .pos-card-size {
  border-color: color-mix(in srgb, var(--joint-patina) 80%, transparent);
  color: var(--accent-2);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--verd-seat) 60%, transparent);
}

/* ---- §24.4 THE SELECTABLE SKILL IS THE TAG IT BECOMES (owner, 2026-09-14:
   "change the selectable skill tags to look just like the skill tags on the
   sprue") ----

   The picker was 72 outlined pills with a 3px category rail, sat directly
   under a sheet of DRAWN tags that the pills turn into when you press them.
   Two vocabularies for one object, and the pill was the one the customer met
   first.

   PAINTED FROM THE PART'S OWN TABLE, NOT A MATCHING COPY. render.js stamps
   `data-cat` with the same pigment letter the drawn tag carries, and
   css/v3/parts.css already resolves that letter to `--tag-face` and derives
   the whole ladder from it — crown, belly, foot, lit, lo, and `--tag-ink` for
   the dry-brushed lettering. So none of those values appear here: this rule
   only assembles them into a plate. Repaint the product and the picker moves
   with it, which a second colour table could never promise.

   THE RAIL GOES. It existed to carry the category on a neutral pill; the tag
   IS the category now, and a coloured stripe down the side of a coloured plate
   is the same fact twice. The category dot in the section head still carries
   it for anyone who cannot use hue (DESIGN-SYSTEM §7).

   FLAT-BOTTOMED, NOT A PILL. A printed tag is a rounded rectangle in a runner,
   which is what `--r-1` gives at this size; `--r-pill` would make it a lozenge
   and nothing on the sprue is a lozenge. */
.skill-card--tap[data-cat] {
  border: 0;
  border-radius: var(--r-1);
  background-color: var(--tag-face);
  background-image: var(--fx-tag-plate);
  box-shadow:
    inset 0 1px 0 var(--tag-lit),
    inset 0 -2px 0 var(--tag-lo),
    0 1px 2px color-mix(in srgb, var(--ink-950) 62%, transparent);
  transition: box-shadow var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
/* The code is CAST into the plate, the same dry-brushed bone the drawing
   letters its codes in, with the mould's own shadow under it. */
/* LETTERED IN THE FACE THE TAG WILL BE CUT IN (owner, 2026-09-14: "we need to
   combine everything together so we are on the same page"). --font-print is
   what js/ctd-parts.js draws every printed label through, and the lettering
   picker sets it on <body> — so choosing Ironjaw re-letters the drawn tags on
   the sprue AND these, together, with no redraw. Lettering the picker in
   --font-display would have made the chip a UI label that merely looked like a
   tag; this makes it the same object seen twice. */
.skill-card--tap[data-cat]:not(.has-qty) .card-name {
  color: var(--tag-ink);
  font-family: var(--font-print), var(--font-display);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
  text-shadow: var(--fx-tag-cast);
}
/* PRESSED, NOT HIGHLIGHTED. These are physical now, so the hover is the plate
   coming up off the runner and the active state is it going down — a border
   colour change would be the pill's idiom on an object that no longer is one. */
.skill-card--tap[data-cat]:hover,
.skill-card--tap[data-cat]:focus-visible {
  box-shadow:
    inset 0 1px 0 var(--tag-lit),
    inset 0 -2px 0 var(--tag-lo),
    inset 0 0 0 1px color-mix(in srgb, var(--bone-100) 30%, transparent),
    0 4px 9px -3px color-mix(in srgb, var(--ink-950) 78%, transparent);
  outline: none;
}
.skill-card--tap[data-cat]:active {
  transform: translateY(1px);
  box-shadow:
    inset 0 2px 3px -1px color-mix(in srgb, var(--ink-950) 70%, transparent),
    inset 0 -1px 0 var(--tag-lo);
}
/* ON THE SHEET ALREADY. A tag that has been taken is snapped off the runner,
   so the card that produced it reads as the empty window it left behind — the
   plate goes, the seat stays, and the tally says how many came off it. */
.skill-card--tap[data-cat].has-qty {
  background-image: none;
  background-color: color-mix(in srgb, var(--tag-face) 34%, transparent);
  box-shadow:
    inset 0 2px 4px -2px color-mix(in srgb, var(--ink-950) 80%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--tag-lit) 55%, transparent);
}
/* A SNAPPED-OFF TAG IS STILL THE SAME OBJECT. It loses the cast lettering,
   because there is no plate left to cast into, but keeping the UI font here
   put one Barlow row in a grid of print-face ones and read as a different
   kind of thing rather than the same thing, taken. */
.skill-card--tap[data-cat].has-qty .card-name {
  color: var(--text-2);
  font-family: var(--font-print), var(--font-display);
  text-transform: uppercase;
  letter-spacing: var(--track-caps);
}
/* The tally is brass, like every other count on this kit. */
.skill-card--tap[data-cat] .skill-card__count {
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  color: var(--ink-950);
  border-radius: 3px;
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 50%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent);
  text-shadow: var(--fx-tag-emboss);
}
/* The rail belonged to the neutral pill — see the note above. */
.skill-card--tap[data-cat][class*="cat-"] { border-left: 0; }


/* ---- §24.5 AN EMPTY POOL IS A VACANT BAY, NOT A GREY SENTENCE ----

   The last flat rectangle on step 3, and the one a customer meets FIRST: land
   on the progress step with nothing picked and the biggest object in the left
   column was `padding: 24px; text-align: center; color: var(--text-3)` — one
   grey line in a dark well, under a heading, beside a rack of stamped trays.

   NO SPRUE IS DRAWN HERE, and that is a decision rather than an omission.
   renderProgressSheets() exists to draw what the ORDER CONTAINS (its own
   header argues this at length, against drawing the pool); an empty pool ships
   no sprue, so a frame drawn here would be the first picture in the app that
   disagrees with the payload. What the panel can honestly be is the SPACE that
   sprue will arrive in.

   SO IT IS THE BAY, IN THE LANGUAGE §24.1 ALREADY WROTE. An empty roster slot
   is the same pressing as a full one, unlit, wearing the diagonal caution
   hatch a workshop paints on a space meant to be filled, with the bolt holes
   left open because an unfilled bay is not bolted shut. All four of those are
   tokens already on this page. Nothing new is invented for an empty state.

   TWO EMPTIES, AND THEY ARE NOT THE SAME EMPTY (see the note in render.js):
   `--vacant` is capacity waiting to be spent and gets the hatch; `--nopool` is
   a prerequisite, and hatching out a space the roster has not earned yet would
   promise something the customer has not bought. */
#extraTagsCards .extra-tags-empty {
  position: relative;
  flex: 1 1 100%;
  display: flex; flex-direction: column; align-items: center;
  gap: var(--s-2);
  padding: var(--s-6) var(--s-5);
  border-radius: var(--r-1);
  text-align: center;
}
#extraTagsCards .extra-tags-empty--vacant {
  background-image: var(--fx-tray-vacant), var(--fx-tooth), var(--fx-tooth-2);
  background-size: auto, 3px 3px, 7px 7px;
  box-shadow: var(--fx-deck-edge);
}
/* The four open holes. One pseudo-element carrying the same drilled hole four
   times is cheaper than four spans and cannot be tabbed into; it is inset 0 on
   the panel rather than in the background list so the hatch underneath keeps
   its own sizing. */
#extraTagsCards .extra-tags-empty--vacant::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background-image: var(--fx-bolt-hole), var(--fx-bolt-hole), var(--fx-bolt-hole), var(--fx-bolt-hole);
  background-repeat: no-repeat;
  background-size: 9px 9px;
  background-position:
    10px 10px, calc(100% - 10px) 10px,
    10px calc(100% - 10px), calc(100% - 10px) calc(100% - 10px);
}
/* THE COUNT IS THE NEWS, so it is a fitting rather than the tail of a
   sentence: the same brass tag as a roster bay number and a bench ticket's
   quantity, one size up because this one is the only thing on its panel.
   It is NOT aria-hidden — left in the reading order the panel still says
   "20 slots in your pool", which is the sentence it replaced. */
#extraTagsCards .extra-tags-empty__tag {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 48px; padding: 3px var(--s-2);
  border-radius: 3px;
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 55%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--joint-lo) 90%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
  color: var(--ink-950);
  font-family: var(--font-display); font-weight: 700;
  font-size: var(--fs-20); font-variant-numeric: tabular-nums;
  text-shadow: var(--fx-tag-emboss);
}
#extraTagsCards .extra-tags-empty__title {
  margin: 0;
  font-family: var(--font-display); font-weight: 500;
  font-size: var(--fs-13); text-transform: uppercase; letter-spacing: var(--track-caps);
  color: var(--bone-300);
  text-shadow: var(--fx-plate-stamp);
}
#extraTagsCards .extra-tags-empty__note {
  margin: 0; max-width: 44ch;
  font-size: var(--fs-12); color: var(--text-3);
}
/* No pool: no hatch and no hardware. A bay that has not been earned is not
   marked out, and the title is struck into the empty tray the way an unfilled
   roster slot's number is (--fx-bay-deboss), not raised off a plate. */
#extraTagsCards .extra-tags-empty--nopool .extra-tags-empty__title {
  color: color-mix(in srgb, var(--bone-100) 30%, transparent);
  text-shadow: var(--fx-bay-deboss);
}

/* ---- §24.6 THE PICKER STRIP IS THE BIN'S LABEL PLATE ----

   Everything below this strip became a moulded tag in §24.4, and the strip
   that searches and filters them stayed `background: var(--bg)` with a bare
   `<input>` in it. Under `color-scheme: dark` that input is a BROWSER default
   — the one object in the builder nobody drew — sitting 8px above seventy-two
   hand-lit plates.

   NOT A SECOND CHANNEL. The obvious move is to give it the builder bar's own
   material, and it is wrong for the reason the nav settled in 2026-09-05 and
   §23 repeats: this strip sticks 8px under that bar, and two identical dark
   recesses that close on each other read as one broken header. It is a
   different KIND of object and it should be made of a different thing.

   IT IS THE LABEL PLATE OFF THE FRONT OF A PARTS BIN — which is exactly what
   it does (what is in this bin, and how many are left) and exactly what sits
   at the top of every tray on the previous step. So it takes --fx-tray-lip,
   the folded rim a roster card's head strip already rides on, and the two end
   bolts that hold that strip on. The rack and the bin are now visibly the same
   kit, two steps apart.

   OPAQUE ON PURPOSE: the lip is a translucent ramp, so the strip carries its
   own --bg-alt underneath it. It is sticky over a scrolling catalogue, and a
   see-through label plate with tags sliding behind it is worse than the flat
   band it replaced. */
.v2-tagpicker__bar {
  position: sticky;
  padding: var(--s-2) var(--s-5);
  border-radius: var(--r-1);
  background-color: var(--bg-alt);
  background-image: var(--fx-tray-lip), var(--fx-tooth), var(--fx-tooth-2);
  background-size: auto, 3px 3px, 7px 7px;
  box-shadow: var(--fx-tray-lip-edge);
}
/* The two bolts that hold the plate on — the §24.1 fitting at the same scale.
   Absolutely positioned, so they are not flex items on a wrapping bar, and
   pointer-events off so neither one eats the search field or a filter.
   --s-5 of inline padding is what clears them: at --s-3 the left dome landed
   on the search box's own edge. */
.v2-tagpicker__bar::before,
.v2-tagpicker__bar::after {
  content: ""; position: absolute; top: 50%; width: 9px; height: 9px;
  margin-top: -4.5px;
  background-image: var(--fx-rivet);
  background-repeat: no-repeat; background-position: center;
  pointer-events: none;
}
.v2-tagpicker__bar::before { left: 5px; }
.v2-tagpicker__bar::after { right: 5px; }

/* ---- the search is a milled slot, not a browser box ----
   `.ctd-input`'s recipe, applied to the label rather than the input, because
   the icon sits inside it: a near-black channel cut into the plate with the
   field transparent inside it. This is the site's own field (--fx-channel is
   the same recess the nav boards sit in); the only reason it was missing is
   that this markup never used the component. */
.v2-tagpicker__search {
  min-height: var(--control-h-sm);
  padding: 0 var(--s-3);
  border: var(--bw) solid var(--line-2);
  border-radius: var(--r-1);
  background-color: var(--ink-950);
  box-shadow: var(--fx-channel);
}
.v2-tagpicker__search:focus-within { border-color: var(--focus); }
.v2-tagpicker__search .ctd-i { color: var(--text-3); }
.v2-tagpicker__search input {
  min-height: var(--control-h-sm);
  background: transparent; border: 0; outline: none;
  color: var(--text);
}
.v2-tagpicker__search input::placeholder { color: var(--text-3); }

/* ---- a filter is a CONTROL, and must not look like a part ----
   The chips sit 8px above a grid of tags that §24.4 made into real mouldings,
   so the one thing they must not become is more tags: pressing a filter
   changes what you are shown, pressing a tag changes what you are sent. Two
   different acts. They are the site's casting (2026-09-16) — the same object
   every button and every wizard beat is — gunmetal, with the chosen one in
   gold. A control on this site is a casting and a part is a pewter tag, and
   that is true on one screen at one time. */
.v2-tagchip {
  --btn-c: var(--btn-cut-sm); --btn-b: var(--btn-bevel-sm);
  --btn-ci: calc(var(--btn-c) - var(--btn-b) * 0.41);
  --btn-clip: polygon(
      var(--btn-c) 0, calc(100% - var(--btn-c)) 0, 100% var(--btn-c),
      100% calc(100% - var(--btn-c)), calc(100% - var(--btn-c)) 100%,
      var(--btn-c) 100%, 0 calc(100% - var(--btn-c)), 0 var(--btn-c));
  --btn-clip-in: polygon(
      var(--btn-ci) 0, calc(100% - var(--btn-ci)) 0, 100% var(--btn-ci),
      100% calc(100% - var(--btn-ci)), calc(100% - var(--btn-ci)) 100%,
      var(--btn-ci) 100%, 0 calc(100% - var(--btn-ci)), 0 var(--btn-ci));
  --btn-rim: var(--fx-btn-rim-gun); --btn-rim-press: var(--fx-btn-rim-gun-press);
  --btn-face: var(--fx-btn-face-gun);
  position: relative; isolation: isolate;
  border-color: transparent; border-radius: 0; background: none;
  color: var(--bone-300); text-shadow: var(--fx-btn-engrave-light);
  filter: var(--fx-btn-cast);
  transition: filter var(--t-base) var(--ease), transform var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.v2-tagchip::before, .v2-tagchip::after { content: ""; position: absolute; pointer-events: none; }
.v2-tagchip::before { inset: 0; z-index: -2; clip-path: var(--btn-clip); background: var(--btn-rim); }
.v2-tagchip::after { inset: var(--btn-b); z-index: -1; clip-path: var(--btn-clip-in); background-image: var(--btn-face); background-repeat: no-repeat; transition: filter var(--t-fast) var(--ease); }
.v2-tagchip > * { position: relative; z-index: 1; }
.v2-tagchip:hover { color: var(--btn-hot-ink); }
.v2-tagchip:hover::before { background: var(--fx-btn-rim-gold); }
.v2-tagchip:hover::after { filter: brightness(1.22); }
.v2-tagchip:active { transform: translateY(2px); filter: var(--fx-btn-cast-press); }
.v2-tagchip:active::before { background: var(--btn-rim-press); }
.v2-tagchip:active::after { filter: brightness(0.9); }
/* The chosen filter is the gold casting, which is what makes it read as
   CHOSEN rather than merely as filled. It replaced a bone-white pill, then a
   bronze plaque; both were a third material on a screen that only has two. */
.v2-tagchip.is-active {
  --btn-rim: var(--fx-btn-rim-gold); --btn-rim-press: var(--fx-btn-rim-gold-press); --btn-face: var(--fx-btn-face-gold);
  border-color: transparent; background: none; box-shadow: none;
  color: var(--on-gold); text-shadow: var(--fx-btn-engrave-dark);
}
.v2-tagchip.is-active:hover { color: var(--on-gold); filter: var(--fx-btn-cast-hot); }
.v2-tagchip.is-active:hover::before { background: var(--btn-rim); }
.v2-tagchip.is-active:hover::after { filter: brightness(1.08); }
/* The category dot's glow is a light bloom for dark ground. On gold it turns
   to mud, so on the chosen casting the dot takes a seated ring instead — the
   colour still carries the category (DESIGN-SYSTEM §7), it is just set into
   the metal rather than glowing off it. */
.v2-tagchip.is-active .ctd-chip__dot {
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink-950) 45%, transparent),
              0 0 0 1px color-mix(in srgb, var(--ink-950) 55%, transparent);
}

/* ---- the tally is a brass tag, and a spent one is a different alloy ----
   "14 left" was --text-3 at 13px, the same grey as the note under it, on the
   one number that decides whether pressing anything does something. It is the
   count fitting every other region on this kit already uses. */
.v2-tagpicker__remaining {
  display: inline-flex; align-items: center;
  margin-left: auto;
  padding: 3px var(--s-2);
  border-radius: 3px;
  background-color: var(--joint-face);
  background-image: var(--fx-plate);
  box-shadow:
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 55%, transparent),
    inset 0 -1px 0 color-mix(in srgb, var(--joint-lo) 90%, transparent),
    inset 0 0 0 1px color-mix(in srgb, var(--joint-lo) 70%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
  color: var(--ink-950);
  font-family: var(--font-display); font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-shadow: var(--fx-tag-emboss);
}
/* Spent, in the badge's second metal rather than a dimmer brass — the rule
   three-metals-and-verdigris settled and the one a big guy's bay tag and a
   capped bench ticket already follow. `is-spent` is set in ui/team/index.js
   beside the text, so the two never disagree. */
.v2-tagpicker__remaining.is-spent {
  background-color: var(--joint-patina);
  box-shadow:
    inset 0 0 0 1px var(--verd-seat),
    inset 0 1px 0 color-mix(in srgb, var(--joint-spec) 34%, transparent),
    0 1px 2px color-mix(in srgb, var(--ink-950) 70%, transparent);
}

/* ---- A FULL POOL GREYS THE CATALOGUE, and this is a v3 REGRESSION FIX ----
   ui/team/index.js has toggled `is-full` on the grid since v1 and says why in
   its own comment ("a press is a no-op, so grey the grid rather than letting
   people tap and collect a toast each time"). The rule that did it
   (`.v2-tagpicker__grid.is-full { opacity: 0.55 }`) is still in the v1 sheet
   and was never carried into css/v3 — so on a full pool the v3 picker gave no
   signal at all, and seventy-two plates that now look genuinely pressable were
   the reward for spending your last slot. Restored at the same value.

   The builder-css coverage gate did not catch it because it compares v3
   against v3; a rule dropped in the migration itself is exactly the shape it
   cannot see. */
.v2-tagpicker__grid.is-full { opacity: 0.55; }

/* ---- nothing matched ----
   The search's own empty is the same kind of object as §24.5's: a space with
   nothing in it. It takes the same vacant pressing, without the hardware,
   because there is no capacity here to count. */
.v2-tagpicker__empty {
  border-radius: var(--r-1);
  background-image: var(--fx-tray-vacant), var(--fx-tooth), var(--fx-tooth-2);
  background-size: auto, 3px 3px, 7px 7px;
  box-shadow: var(--fx-deck-edge);
}
.v2-tagpicker__empty .ctd-i { color: var(--text-3); }

/* ---- the strip must not eat a phone screen ----
   It has been sticky since the declunk and it has always been opaque
   (`background: var(--bg)`), so it has always occluded this much catalogue —
   giving it a material is what made that visible. On a 375px viewport the
   seven filters wrap to three rows and the strip stands 257px tall under a
   64px bar: a third of the screen, permanently, for a control you use once.

   The row scrolls sideways instead, at the breakpoint the grid below already
   turns single-column. Nothing is hidden and nothing moves off the strip; the
   filters simply stop demanding three rows to be reachable. The padding is
   there because --fx-btn-edge casts 5px below each plate and an overflow
   container would clip it into a hard line. */
@media (max-width: 700px) {
  .v2-tagpicker__filters {
    flex: 1 1 100%;
    min-width: 0;                 /* flex default is min-width:auto, which refuses to shrink below the seven plates */
    flex-wrap: nowrap;
    overflow-x: auto;
    padding-block: 4px;
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  .v2-tagpicker__filters::-webkit-scrollbar { display: none; }
  .v2-tagchip { flex: none; }
}
