/* ==========================================================================
   WINGSPAN — Self-hosted fonts
   Archivo + Archivo Narrow, self-hosted so the standalone build's CSP can
   say font-src 'self' without allowing fonts.gstatic.com. Same families,
   same weights, same @font-face shape Google Fonts serves — pulled straight
   from https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700
   &family=Archivo+Narrow:wght@400;600;700&display=swap, latin + latin-ext
   subsets only (player names include accented Latin characters; vietnamese
   and the rest are dropped). Both are variable fonts — one woff2 per family
   per subset covers the whole 400-700 weight range. See build.py, standalone
   branch, for how this gets inlined into app.<hash>.css. License: OFL.txt.
   ========================================================================== */

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/assets/fonts/archivo-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/assets/fonts/archivo-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Archivo Narrow';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/assets/fonts/archivo-narrow-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Archivo Narrow';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("/assets/fonts/archivo-narrow-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}


/* ==========================================================================
   WINGSPAN — Design tokens
   Derived from wingspanscouting.com. See ../brand/BRAND.md for rationale.
   Framework-agnostic on purpose: stack is not yet chosen.
   ========================================================================== */

:root {
  /* --- Brand ------------------------------------------------------------ */
  --ws-ink:        #0D0D0D;  /* ground. NOT #000 — 5% lightness             */
  --ws-bone:       #FAFAF7;  /* warm off-white. NOT #FFF                    */
  --ws-gold:       #FFCC00;  /* emphasis. ONLY ever on ink                  */
  --ws-flare:      #FF2B00;  /* live / gap / attention. Used sparingly      */

  /* --- Surface ladder --------------------------------------------------- */
  --ws-ink-900:    #000000;  /* recessed wells, inputs, table gutters       */
  --ws-ink-800:    #0D0D0D;  /* app ground                                  */
  --ws-ink-700:    #161616;  /* cards, panels                               */
  --ws-ink-600:    #212121;  /* raised / hover                              */
  --ws-ink-500:    #2E2E2E;  /* borders, dividers                           */
  --ws-ink-400:    #3D3D3D;  /* stronger borders                            */

  /* --- Text on ink ------------------------------------------------------ */
  --ws-text:       #FAFAF7;
  --ws-text-dim:   rgba(250, 250, 247, 0.62);
  --ws-text-mut:   rgba(250, 250, 247, 0.38);
  --ws-text-ghost: rgba(250, 250, 247, 0.20);

  /* --- Signal taxonomy (spec §4.8) -------------------------------------- */
  /* Four signal types, four colours. The palette does not grow past this.  */
  --ws-signal-strong:   var(--ws-gold);      /* evidence supports it        */
  --ws-signal-early:    var(--ws-text-dim);  /* pattern visible, thin data  */
  --ws-signal-gap:      var(--ws-flare);     /* below target range          */
  --ws-signal-evidence: transparent;         /* unknown — drawn as absence  */

  --ws-positive:   #4ADE80;  /* verified / above benchmark                  */

  /* --- Type -------------------------------------------------------------
     Display resolves to Tablet Gothic Compressed once licensed for self-
     hosting; Archivo Narrow stands in until then. Single swap point.      */
  --ws-display: "Tablet Gothic Compressed", "Archivo Narrow", "Helvetica Neue Condensed", sans-serif;
  --ws-body:    "Archivo", "Tablet Gothic", system-ui, -apple-system, sans-serif;
  --ws-mono:    ui-monospace, "SF Mono", Menlo, monospace;

  --ws-lh-display: 0.68;   /* the signature. Lines interlock.               */
  --ws-lh-tight:   1.15;
  --ws-lh-body:    1.5;

  /* Display scale — never below 28px, always uppercase, always 700 */
  --ws-fs-display-xl: clamp(3rem, 7vw, 6rem);
  --ws-fs-display-l:  clamp(2.25rem, 5vw, 4rem);
  --ws-fs-display-m:  1.75rem;
  --ws-fs-display-s:  1.25rem;

  /* UI scale */
  --ws-fs-body:   0.9375rem;  /* 15 */
  --ws-fs-small:  0.8125rem;  /* 13 */
  --ws-fs-micro:  0.6875rem;  /* 11 — labels, source tags */

  --ws-ls-micro:  0.08em;
  --ws-ls-label:  0.04em;

  /* --- Geometry --------------------------------------------------------- */
  --ws-radius:    2px;        /* squared. The dot is the only circle.       */
  --ws-radius-pill: 999px;    /* dots and the mark only                     */
  --ws-border:    1px solid var(--ws-ink-500);
  --ws-border-strong: 1px solid var(--ws-ink-400);

  /* --- Space (4px base) -------------------------------------------------- */
  --ws-1: 0.25rem;  --ws-2: 0.5rem;   --ws-3: 0.75rem;  --ws-4: 1rem;
  --ws-5: 1.5rem;   --ws-6: 2rem;     --ws-7: 3rem;     --ws-8: 4rem;
  --ws-9: 6rem;

  /* --- Motion — fades only. No spring, no parallax. ---------------------- */
  --ws-ease:      200ms cubic-bezier(0.2, 0, 0.2, 1);
  --ws-ease-slow: 400ms cubic-bezier(0.2, 0, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}


/* ==========================================================================
   WINGSPAN — Components
   Depends on tokens.css. See ../brand/BRAND.md.
   Rules enforced here:
     · gold only ever on ink        · flat — no gradients, no glows
     · squared — radius 0-2px       · the dot is the only circle
   ========================================================================== */

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

/* The `hidden` attribute must always win, no matter what inline style="..."
   an element also carries — see gen_prototype.py's SB_HTML markers for the
   history (a "hidden" roster filter row and a sign-in password row both
   used to render anyway because an inline display value outranks the UA
   [hidden] rule). Elements toggled with `hidden` now use a class
   (.ws-inline-flex / .ws-block) for their shown-state layout instead of an
   inline display, and this !important is the belt-and-suspenders backstop. */
[hidden] { display: none !important; }

body.ws {
  margin: 0;
  color: var(--ws-text);
  font-family: var(--ws-body);
  font-size: var(--ws-fs-body);
  line-height: var(--ws-lh-body);
  -webkit-font-smoothing: antialiased;
  /* The app ground carries the same pebble crop as the locked screen
     (see body[data-locked] near the end of this file) but far fainter and
     with no vignette — a near-opaque ink veil (rgba matches --ws-ink-800's
     own #0D0D0D) over the image, ~3-4% show-through everywhere, so the
     brand surface reads through the whole signed-in app rather than just
     the sign-in screen. body[data-locked] overrides this with its own
     stronger, vignetted version at equal selector specificity, later in
     this file. */
  background-color: var(--ws-ink-800);
  background-image:
    linear-gradient(rgba(13, 13, 13, 0.965), rgba(13, 13, 13, 0.965)),
    url("/assets/texture-pebble.png");
  background-size: cover, cover;
  background-position: 50% 40%, 50% 40%;
  background-repeat: no-repeat, no-repeat;
}
@media (min-width: 641px) {
  body.ws { background-attachment: fixed; }
}

/* A bare <a> had no rule anywhere in this file — every genuine link (.ws-btn,
   .ws-footer-link, .ws-demo-link, a roster/table row's own name cell) already
   sets its own color, but a plain inline link with no class (a session name,
   an event card title, a "needs attention" row, a footer "Prompts" link)
   fell through to the browser default: blue, purple once visited. This is
   the one base rule that catches all of those — bone text, underlined so it
   still reads as a link, gold only on hover/focus (gold is a scalpel, not
   the resting state of every link on the page). Any more specific class
   above already wins on specificity and is untouched by this. */
a { color: var(--ws-text); text-decoration-color: var(--ws-text-mut); text-underline-offset: 2px; }
a:visited { color: var(--ws-text); }
a:hover { color: var(--ws-gold); text-decoration-color: var(--ws-gold); }
a:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }

/* --- Display type ---------------------------------------------------------
   The signature: compressed, 700, uppercase, line-height 0.68 so lines
   interlock. Word-per-line stacking is the house style — see .ws-stack.     */

.ws-display {
  font-family: var(--ws-display);
  font-weight: 700;
  text-transform: uppercase;
  line-height: var(--ws-lh-display);
  letter-spacing: -0.005em;
  margin: 0;
}
.ws-display--xl { font-size: var(--ws-fs-display-xl); }
.ws-display--l  { font-size: var(--ws-fs-display-l); }
.ws-display--m  { font-size: var(--ws-fs-display-m); line-height: 0.78; }
.ws-display--s  { font-size: var(--ws-fs-display-s); line-height: 0.85; }
.ws-display--gold { color: var(--ws-gold); }

/* One word per line. The headline reads as a wall, not a sentence. */
.ws-stack > span { display: block; }

/* --- Labels ---------------------------------------------------------------
   Compressed uppercase. Wraps onto two lines rather than shrinking or
   truncating — the stacked-label habit is part of the voice.               */

.ws-label {
  font-family: var(--ws-display);
  font-weight: 700;
  font-size: var(--ws-fs-small);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-label);
  line-height: 1.05;
  color: var(--ws-text);
  margin: 0;
}
.ws-label--dim { color: var(--ws-text-dim); }

/* Provenance, not quality. Never a coloured pill — pills imply ranking. */
.ws-micro,
.ws-source {
  font-family: var(--ws-body);
  font-size: var(--ws-fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  color: var(--ws-text-mut);
  line-height: 1.4;
}
.ws-source--verified { color: var(--ws-text-dim); }
.ws-source--unverified { color: var(--ws-text-mut); font-style: italic; letter-spacing: var(--ws-ls-micro); }

/* --- The mark -------------------------------------------------------------
   Five dots: three small above, two large below. Arm span, and a cluster of
   observations. The only circle in the system.                              */

.ws-mark { display: block; height: 1.5rem; width: auto; }
.ws-mark path, .ws-mark circle { fill: currentColor; }

/* --- Confidence track -----------------------------------------------------
   EARLY / DEVELOPING / STRONGER (spec §4.8) rendered against five dots —
   the same five as the mark. A fully-evidenced player IS the logo.
   Linear here because it sorts and scans in a table; the cluster
   arrangement is reserved for the profile-strength hero.                    */

.ws-conf { display: inline-flex; gap: 4px; align-items: center; vertical-align: middle; }
.ws-conf i {
  width: 7px; height: 7px;
  border-radius: var(--ws-radius-pill);
  border: 1px solid var(--ws-ink-400);
  background: transparent;
  display: block;
}
.ws-conf[data-level="early"]      i:nth-child(-n+1),
.ws-conf[data-level="developing"] i:nth-child(-n+3),
.ws-conf[data-level="stronger"]   i:nth-child(-n+5) {
  background: var(--ws-gold);
  border-color: var(--ws-gold);
}
.ws-conf-text {
  font-family: var(--ws-display);
  font-weight: 700;
  font-size: var(--ws-fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  color: var(--ws-text-dim);
  margin-left: var(--ws-2);
}

/* --- Signals --------------------------------------------------------------
   Four types, four treatments. Evidence gaps are drawn as ABSENCE — a hollow
   ring, no fill, no colour. Missing data must look missing.                 */

.ws-signal {
  display: flex; gap: var(--ws-3);
  align-items: flex-start;
  padding: var(--ws-3) 0;
  border-bottom: 1px solid var(--ws-ink-700);
}
.ws-signal:last-child { border-bottom: 0; }
.ws-signal::before {
  content: "";
  flex: 0 0 auto;
  width: 9px; height: 9px;
  margin-top: 0.45em;
  border-radius: var(--ws-radius-pill);
  border: 1px solid transparent;
}
.ws-signal--strong::before   { background: var(--ws-gold); }
.ws-signal--early::before    { background: var(--ws-text-dim); }
.ws-signal--gap::before      { background: var(--ws-flare); }
.ws-signal--evidence::before { background: transparent; border-color: var(--ws-ink-400); }

.ws-signal--early    .ws-signal-text { color: var(--ws-text-dim); }
.ws-signal--evidence .ws-signal-text { color: var(--ws-text-mut); }
.ws-signal-text { margin: 0; font-size: var(--ws-fs-body); }
.ws-signal-note { display: block; margin-top: 2px; }

/* Legend */
.ws-legend { display: flex; flex-wrap: wrap; gap: var(--ws-4); }
.ws-legend-item { display: flex; align-items: center; gap: var(--ws-2); }
.ws-legend-dot {
  width: 9px; height: 9px; border-radius: var(--ws-radius-pill);
  border: 1px solid transparent; flex: 0 0 auto;
}

/* --- Stat cell ------------------------------------------------------------
   Big number in the display face does the work. No card needed.             */

.ws-stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ws-stat-value {
  font-family: var(--ws-display);
  font-weight: 700;
  font-size: 2.25rem;
  line-height: 0.9;
  font-variant-numeric: tabular-nums;
  color: var(--ws-text);
}
.ws-stat-value--gold { color: var(--ws-gold); }
.ws-stat-unit {
  font-size: var(--ws-fs-small);
  color: var(--ws-text-dim);
  font-family: var(--ws-body);
  letter-spacing: 0;
  text-transform: none;
}
.ws-stat--sm .ws-stat-value { font-size: 1.5rem; }

/* --- Competition level ----------------------------------------------------
   Levels 1-5 (spec §8.4), where 1 is the TOP. Position is marked, never
   "filled up to" — filling would imply 5 is best. Numeral always shown.     */

.ws-level { display: flex; flex-direction: column; gap: var(--ws-2); }
.ws-level-track { display: flex; gap: 3px; }
.ws-level-track span {
  height: 3px; flex: 1 1 0;
  background: var(--ws-ink-500);
}
.ws-level-track span[data-current] { background: var(--ws-gold); }
.ws-level-caption { display: flex; gap: var(--ws-2); align-items: baseline; }

/* --- Panels --------------------------------------------------------------- */

.ws-panel {
  background: var(--ws-ink-700);
  border: var(--ws-border);
  border-radius: var(--ws-radius);
  padding: var(--ws-5);
}
.ws-panel--flush { padding: 0; }
.ws-panel--well  { background: var(--ws-ink-900); }

/* The single emphasis block per screen. Gold is a scalpel. */
.ws-callout {
  border: 1px solid var(--ws-gold);
  border-radius: var(--ws-radius);
  padding: var(--ws-4) var(--ws-5);
  display: flex; gap: var(--ws-4); align-items: flex-start;
  background: var(--ws-ink-700);
}
.ws-callout-rule { width: 2px; align-self: stretch; background: var(--ws-gold); flex: 0 0 auto; }

.ws-section-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--ws-4);
  padding-bottom: var(--ws-3);
  margin-bottom: var(--ws-4);
  border-bottom: var(--ws-border);
}

/* --- Record field ---------------------------------------------------------
   Every fact carries its provenance. Value, then source, then date.         */

.ws-field { padding: var(--ws-3) 0; border-bottom: 1px solid var(--ws-ink-700); }
.ws-field:last-child { border-bottom: 0; }
.ws-field-value {
  font-size: var(--ws-fs-body);
  margin: 2px 0 0;
  font-variant-numeric: tabular-nums;
}
.ws-field-value--empty { color: var(--ws-text-ghost); font-style: italic; }
.ws-field-meta { display: flex; gap: var(--ws-2); margin-top: 3px; flex-wrap: wrap; }

.ws-grid { display: grid; gap: var(--ws-4) var(--ws-6); }
.ws-grid--2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ws-grid--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ws-grid--4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

/* --- Table ----------------------------------------------------------------
   Dense on purpose. This is a professional tool for comparing many players;
   density is on-brand. Do not inflate rows into cards.                      */

.ws-table-wrap { overflow-x: auto; }
.ws-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.ws-table th {
  text-align: left;
  font-family: var(--ws-display);
  font-weight: 700;
  font-size: var(--ws-fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  color: var(--ws-text-mut);
  padding: var(--ws-2);
  border-bottom: var(--ws-border-strong);
  white-space: nowrap;
  position: sticky; top: 0;
  background: var(--ws-ink-800);
}
.ws-table td {
  /* Tightened from var(--ws-3) — the sidebar (components.css's .ws-sidebar)
     leaves less width for a wide table than the old full-bleed topbar did,
     and "dense on purpose" above already covers a smaller padding floor. */
  padding: var(--ws-2);
  border-bottom: 1px solid var(--ws-ink-700);
  font-size: var(--ws-fs-small);
  white-space: nowrap;
}
.ws-table tbody tr:hover { background: var(--ws-ink-700); cursor: pointer; }
.ws-table tbody tr:focus-visible { outline: 1px solid var(--ws-gold); outline-offset: -1px; }
.ws-table .ws-num { text-align: right; }
.ws-id { font-family: var(--ws-mono); font-size: var(--ws-fs-micro); color: var(--ws-text-mut); }
/* Roster's free-text-length columns — a role list, a club name or a
   grade/track/follow-up combo can each run long enough on their own to be
   most of the wrap's own overflow (see the sidebar/table note above
   .ws-table-wrap). Ellipsis, not the plain nowrap every other column keeps,
   so a long value reads as "there is more" rather than getting sliced
   mid-glyph at the wrap's own edge; rosterRow() puts the untruncated text in
   a title attribute so it is still reachable without a horizontal scroll. */
.ws-table td[data-l="Roles"] { max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
.ws-table td[data-l="Scout-only"] { max-width: 130px; overflow: hidden; text-overflow: ellipsis; }
.ws-table td[data-l="Team"] { max-width: 130px; overflow: hidden; text-overflow: ellipsis; }
/* ID and Height are compact, fixed-shape values (a code, a measurement) —
   the two columns the owner's fix explicitly named as safe to lose padding
   on, since nothing in them ever needs the same breathing room as a name. */
.ws-table td[data-l="ID"], .ws-table td[data-l="Height"] { padding-left: var(--ws-1); padding-right: var(--ws-1); }
.ws-name { font-weight: 600; color: var(--ws-text); }

/* --- Controls ------------------------------------------------------------- */

.ws-btn {
  font-family: var(--ws-display);
  font-weight: 700;
  font-size: var(--ws-fs-small);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-label);
  padding: var(--ws-3) var(--ws-5);
  border-radius: var(--ws-radius);
  border: 1px solid var(--ws-ink-400);
  background: transparent;
  color: var(--ws-text);
  text-decoration: none; /* an <a class="ws-btn"> reads as a button, never a link */
  cursor: pointer;
  transition: background var(--ws-ease), border-color var(--ws-ease), color var(--ws-ease);
}
.ws-btn:hover { background: var(--ws-ink-600); border-color: var(--ws-text-mut); }
.ws-btn:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }
/* Gold on ink only. One primary per screen. */
.ws-btn--primary { background: var(--ws-gold); border-color: var(--ws-gold); color: var(--ws-ink); }
.ws-btn--primary:hover { background: #FFD633; border-color: #FFD633; }

.ws-chip {
  font-size: var(--ws-fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  padding: var(--ws-1) var(--ws-3);
  border: 1px solid var(--ws-ink-500);
  border-radius: var(--ws-radius);
  background: transparent;
  color: var(--ws-text-dim);
  cursor: pointer;
  font-family: var(--ws-body);
  transition: all var(--ws-ease);
}
.ws-chip:hover { border-color: var(--ws-text-mut); color: var(--ws-text); }
.ws-chip[aria-pressed="true"] { border-color: var(--ws-gold); color: var(--ws-gold); }
.ws-chip:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }

.ws-input {
  background: var(--ws-ink-900);
  border: var(--ws-border);
  border-radius: var(--ws-radius);
  color: var(--ws-text);
  font-family: var(--ws-body);
  font-size: var(--ws-fs-small);
  padding: var(--ws-2) var(--ws-3);
  width: 100%;
}
.ws-input::placeholder { color: var(--ws-text-mut); }
.ws-input:focus { outline: none; border-color: var(--ws-gold); }
/* <select> and <textarea> take the same dark-well treatment as-is — no
   separate rule needed, other than letting a textarea grow. */
textarea.ws-input { resize: vertical; }

/* A single boolean, e.g. "year only" on date of birth. Nothing else in the
   system needed a lone checkbox until now — everywhere else is a toggle
   chip or a button group. */
.ws-checkbox { display: inline-flex; align-items: center; gap: var(--ws-2); cursor: pointer; }
.ws-checkbox input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--ws-gold); cursor: pointer; }

/* Button-styled checkbox group — visually the .ws-opt idiom, but data-entry
   groups (Position, Roles, Follow-up) must NOT run through the questionnaire's
   max-selection logic, so they get their own class and their own handler. */
.ws-checkgroup { display: flex; flex-wrap: wrap; gap: var(--ws-2); }
.ws-check {
  font-family: var(--ws-body);
  font-size: var(--ws-fs-small);
  padding: var(--ws-2) var(--ws-4);
  border: var(--ws-border);
  border-radius: var(--ws-radius);
  background: transparent;
  color: var(--ws-text-dim);
  cursor: pointer;
  transition: all var(--ws-ease);
}
.ws-check:hover { border-color: var(--ws-text-mut); color: var(--ws-text); }
.ws-check[aria-pressed="true"] { background: var(--ws-gold); border-color: var(--ws-gold); color: var(--ws-ink); font-weight: 600; }
.ws-check:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }

/* --- Navigation -----------------------------------------------------------
   Flare (#FF2B00) appears exactly once on the marketing site: the active
   nav underline — here it marks the active sidebar/drawer row instead (a
   left edge, not an underline; see .ws-nav button[aria-current] below), same
   job either way. Left sidebar, not a topbar row — scales better as items
   grow, especially on mobile (owner). Persistent ≥900px, a slide-in drawer
   under it (see .ws-sidebar's own media queries, and navOpenDrawer()/
   navCloseDrawer() in gen_prototype.py, which reuse the evaluation drawer's
   mechanics: focus trap, Escape, a scrim click). #main-nav (inside) is the
   one nav list at every width — applyRoute() fills it from ROLE_NAV.      */

.ws-topbar {
  display: flex; align-items: center; gap: var(--ws-6);
  padding: var(--ws-4) var(--ws-6);
  border-bottom: var(--ws-border);
  background: var(--ws-ink-800);
  position: sticky; top: 0; z-index: 10;
}
.ws-nav-toggle {
  display: none; /* ≥900px: turned back on by the sidebar's own <900px query below */
  min-width: 44px; min-height: 44px;
  align-items: center; justify-content: center;
  background: none; border: 1px solid var(--ws-ink-400); border-radius: var(--ws-radius);
  color: var(--ws-text); cursor: pointer;
}
.ws-nav-toggle svg { width: 22px; height: 22px; stroke: currentColor; stroke-width: 7; fill: none; stroke-linecap: square; }
.ws-nav-toggle:hover { color: var(--ws-gold); border-color: var(--ws-gold); }
.ws-nav-toggle:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }
/* Phone-width logo in the topbar: the way back to Home (there is no Home nav
   item). >=900px the sidebar's own lockup does the same job. */
.ws-topbar-lockup { display: inline-flex; align-items: center; margin-left: var(--ws-3); }
.ws-topbar-lockup img { height: 22px; width: auto; display: block; }

.ws-sidebar-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(13, 13, 13, 0.72); }
.ws-sidebar {
  position: fixed; top: 0; left: 0; bottom: 0; z-index: 20;
  /* 200px, not 220 — narrowed one pass in: a wide table (roster) needs
     every spare pixel of the content column it competes with at 1280/1440
     (see .ws-shell's own table-bearing-screen override below). */
  width: 200px;
  display: flex; flex-direction: column; gap: var(--ws-6);
  padding: var(--ws-5) var(--ws-4);
  background: var(--ws-ink-800); border-right: var(--ws-border);
  overflow-y: auto; -webkit-overflow-scrolling: touch;
}
/* The lockup is a wide horizontal mark in a 200px column: let it scale to the
   column rather than keeping a fixed height it cannot fit (it overflowed when
   the sidebar narrowed from 220 to 200). No extra side padding — the sidebar's
   own var(--ws-4) is the gutter. */
.ws-sidebar-lockup { display: block; padding: 0; flex: 0 0 auto; max-width: 100%; }
.ws-sidebar-lockup img { display: block; width: 100%; max-width: 148px; height: auto; }
.ws-nav { display: flex; flex-direction: column; gap: var(--ws-2); }
.ws-nav button {
  display: flex; align-items: center; gap: var(--ws-4);
  width: 100%; min-height: 44px;
  background: none; border: 0; border-left: 2px solid transparent;
  padding: var(--ws-2) var(--ws-3);
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-small); text-transform: uppercase;
  letter-spacing: var(--ws-ls-label); text-align: left;
  color: var(--ws-text-dim); cursor: pointer;
  transition: color var(--ws-ease), background var(--ws-ease);
}
.ws-nav button svg { width: 20px; height: 20px; stroke: currentColor; stroke-width: 7; fill: none; stroke-linecap: square; flex: 0 0 auto; }
.ws-nav button:hover { color: var(--ws-text); background: var(--ws-ink-700); }
.ws-nav button[aria-current="page"] { color: var(--ws-text); background: var(--ws-ink-700); border-left-color: var(--ws-flare); }
.ws-nav button:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: -2px; }

@media (min-width: 900px) {
  body.ws { padding-left: 200px; }
  .ws-sidebar-scrim, .ws-nav-toggle, .ws-topbar-lockup { display: none !important; }
}
@media (max-width: 899.98px) {
  .ws-sidebar {
    width: 85vw; z-index: 60; /* above .ws-sidebar-scrim (59) */
    transform: translateX(-100%); transition: transform var(--ws-ease);
    padding-bottom: calc(var(--ws-5) + env(safe-area-inset-bottom, 0px));
  }
  .ws-sidebar.is-open { transform: translateX(0); }
  .ws-nav-toggle { display: inline-flex; }
  .ws-nav button { min-height: 48px; }
}

/* --- Player shell -----------------------------------------------------------
   view-player (scout) and view-playerrole (player-role preview) — a
   persistent header + a tab strip, one panel visible at a time (see
   applyRoute/paintPlayerShellTabs, gen_prototype.py). Nested experience over
   separate pages: switching tabs never re-renders .player-shell-head. */
.player-shell-head {
  display: flex; gap: var(--ws-5); align-items: flex-start;
  padding-bottom: var(--ws-5); margin-bottom: var(--ws-5);
  border-bottom: var(--ws-border);
}
.ws-tabs {
  display: flex; flex-wrap: wrap; gap: var(--ws-2);
  margin-bottom: var(--ws-6); border-bottom: var(--ws-border);
}
.ws-tab {
  display: inline-flex; align-items: center;
  min-height: 44px; padding: var(--ws-3) var(--ws-4);
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-small); text-transform: uppercase;
  letter-spacing: var(--ws-ls-label);
  color: var(--ws-text-dim); text-decoration: none;
  border-bottom: 2px solid transparent; margin-bottom: -1px;
  cursor: pointer; transition: color var(--ws-ease), border-color var(--ws-ease);
}
.ws-tab:hover { color: var(--ws-text); }
.ws-tab[aria-selected="true"] { color: var(--ws-text); border-bottom-color: var(--ws-flare); }
.ws-tab:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }
@media (max-width: 640px) {
  .player-shell-head { gap: var(--ws-4); }
  .ws-tabs { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .ws-tab { flex: 0 0 auto; }
}

/* --- Utility --------------------------------------------------------------- */
.ws-shell { max-width: 1400px; margin: 0 auto; padding: var(--ws-6); }
/* The 1400px cap predates the sidebar and made sense when it was the only
   thing keeping a wide table off an ultrawide monitor's full width — the
   sidebar already limits the content column now, and every prose block on
   these screens already carries its own ch-based max-width (e.g. the
   playerhome/questionnaire disclaimers), so nothing here gets harder to
   read. Scoped to the screens that actually hold a wide table rather than
   raised sitewide. */
#view-roster.ws-shell, #view-event.ws-shell,
#view-combines.ws-shell, #view-combine.ws-shell, #view-history.ws-shell {
  max-width: none;
}
.ws-dim { color: var(--ws-text-dim); }
/* Demo-mode external links (sdLinkify()/sdShowDemoNotice(), gen_prototype.py
   SB block): a real-looking, keyboard-reachable link that never navigates —
   clicking it reveals the sibling .ws-demo-link-notice instead of a new tab. */
.ws-demo-link { color: var(--ws-gold); text-decoration: underline; cursor: pointer; }
.ws-demo-link-notice { margin: var(--ws-1) 0 0; }
.ws-mut { color: var(--ws-text-mut); }
.ws-gold { color: var(--ws-gold); }
.ws-flare { color: var(--ws-flare); }
.ws-rule { border: 0; border-top: var(--ws-border); margin: var(--ws-6) 0; }
/* A search snippet's matched words — server-marked, never raw markup. See
   renderSnippet() in the roster search block: esc() runs first, this is only
   ever applied to the two literal marker characters after that. */
mark { background: rgba(255, 204, 0, 0.28); color: inherit; border-radius: var(--ws-radius); padding: 0 2px; }
.ws-vis-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap;
}
/* Shown-state layout for elements toggled with the `hidden` attribute — see
   the [hidden] rule above. Never combine `hidden` with an inline display. */
.ws-inline-flex { display: flex; }
.ws-block { display: block; }

/* Bug fix: Save draft / Save player measured 43px tall — 1px under the
   44px minimum tap target, on a pair of buttons where a mis-tap between
   "draft" and "final" actually matters. Sitewide floor, not just these two. */
#sub-save, #sub-save-draft { min-height: 44px; }

@media (max-width: 900px) {
  .ws-grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .ws-grid--3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 600px) {
  .ws-grid--2, .ws-grid--3, .ws-grid--4 { grid-template-columns: minmax(0, 1fr); }
  .ws-shell { padding: var(--ws-4); }
  .ws-topbar { padding: var(--ws-3) var(--ws-4); gap: var(--ws-4); flex-wrap: wrap; }
  /* Bug fix: #view-event's own header (title + "Edit event"/"All events")
     overflowed ~20px at 375-390px — the title block and the button pair
     never wrapped onto their own rows. Scoped to this one header, not every
     .ws-section-head (most are a single button and already fit). */
  #view-event > .ws-section-head { flex-wrap: wrap; }
}

/* --- Player photograph ----------------------------------------------------
   Stored clean, shown clean. The halftone is a brand treatment for marketing
   surfaces — a coach evaluating a prospect needs to see the player.

   The empty state is not a generic avatar. It says WHY there is no image,
   because "no photo on file" and "consent not granted" are different facts and
   a scout needs to know which one they are looking at.                       */

.ws-photo {
  position: relative;
  display: block;
  aspect-ratio: 4 / 5;
  background: var(--ws-ink-900);
  border: var(--ws-border);
  border-radius: var(--ws-radius);
  overflow: hidden;
  flex: 0 0 auto;
}
.ws-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }

.ws-photo--empty {
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: var(--ws-3);
  padding: var(--ws-4);
  text-align: center;
}
.ws-photo--empty .ws-mark { color: var(--ws-ink-400); height: 1.25rem; }
/* Withheld for consent, not merely absent — flare marks it as actionable. */
.ws-photo--withheld { border-color: var(--ws-flare); }
.ws-photo--withheld .ws-mark { color: var(--ws-flare); }

/* Provenance sits under the image like any other fact. */
.ws-photo-meta { display: flex; flex-direction: column; gap: 2px; margin-top: var(--ws-2); }

.ws-photo--thumb {
  aspect-ratio: 1 / 1;
  width: 40px;
  border-radius: var(--ws-radius);
}
.ws-photo--thumb.ws-photo--empty { padding: 0; }
.ws-photo--thumb .ws-mark { height: 0.5rem; }

.ws-photo--sm { width: 72px; }
.ws-photo--lg { width: 180px; }

@media (max-width: 600px) { .ws-photo--lg { width: 128px; } }

/* --- Pebble texture -------------------------------------------------------
   Cropped from the ball in the site hero. A surface texture at low opacity,
   never decoration in its own right — if you can see it as an image, it is
   turned up too far.                                                         */

.ws-textured { position: relative; isolation: isolate; }
.ws-textured::before {
  content: "";
  position: absolute; inset: 0; z-index: -1;
  background-image: url("/assets/texture-pebble.png");
  background-size: cover;          /* one large crop — never tiled */
  background-position: 50% 40%;
  opacity: 0.055;
  mix-blend-mode: screen;
  pointer-events: none;
}

/* --- Film ----------------------------------------------------------------- */

.ws-film-grid { display: grid; gap: var(--ws-5); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }

.ws-film-card { background: var(--ws-ink-700); border: var(--ws-border); border-radius: var(--ws-radius); overflow: hidden; }
.ws-film-card:hover { border-color: var(--ws-text-mut); }

.ws-film-poster { position: relative; aspect-ratio: 16 / 9; background: var(--ws-ink-900); display: block; }
.ws-film-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Play affordance: the dot again, not a rounded glassy button. */
.ws-film-play {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  background: rgba(13, 13, 13, 0.35); border: 0; cursor: pointer; width: 100%; padding: 0;
  transition: background var(--ws-ease);
}
.ws-film-card:hover .ws-film-play { background: rgba(13, 13, 13, 0.15); }
.ws-film-play span {
  width: 46px; height: 46px; border-radius: var(--ws-radius-pill);
  background: var(--ws-gold); color: var(--ws-ink);
  display: flex; align-items: center; justify-content: center;
  font-size: 15px; padding-left: 3px;
}
.ws-film-play:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: -4px; }

.ws-film-badge {
  position: absolute; bottom: var(--ws-2); right: var(--ws-2);
  background: var(--ws-ink); color: var(--ws-text);
  font-size: var(--ws-fs-micro); letter-spacing: var(--ws-ls-micro);
  padding: 2px var(--ws-2); border-radius: var(--ws-radius);
  font-variant-numeric: tabular-nums;
}
.ws-film-type {
  position: absolute; top: var(--ws-2); left: var(--ws-2);
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  padding: 2px var(--ws-2); border-radius: var(--ws-radius);
  background: var(--ws-ink); color: var(--ws-text-dim);
}
/* Full games are what a scout actually needs. Mark them. */
.ws-film-type[data-full] { color: var(--ws-ink); background: var(--ws-gold); }

.ws-film-body { padding: var(--ws-4); }
.ws-film-strip { display: flex; gap: var(--ws-3); overflow-x: auto; padding-bottom: var(--ws-2); }
.ws-film-strip .ws-film-card { flex: 0 0 240px; }

/* --- Scout byline ---------------------------------------------------------- */

.ws-byline { display: flex; gap: var(--ws-4); align-items: center; }
.ws-byline img {
  width: 48px; height: 48px; object-fit: cover; object-position: 50% 18%;
  border-radius: var(--ws-radius); border: var(--ws-border); flex: 0 0 auto;
}
.ws-report-section { margin-top: var(--ws-5); }
.ws-report-section p { margin: var(--ws-2) 0 0; color: var(--ws-text-dim); max-width: 70ch; }

/* --- Role icons -----------------------------------------------------------
   Drawn to match the five icons on the marketing site: single uniform stroke,
   gold, everything contained in a circle of the same diameter. The circle
   matters more than the glyph — it is quoting the dot.                        */

.ws-role-icon { width: 22px; height: 22px; display: block; flex: 0 0 auto; }
.ws-role-icon circle, .ws-role-icon path, .ws-role-icon line {
  stroke: currentColor; stroke-width: 7; fill: none;
  stroke-linecap: square; stroke-linejoin: miter;
}
.ws-role-icon .solid { fill: currentColor; stroke: none; }

/* --- Role switcher --------------------------------------------------------
   PROTOTYPE DEVICE ONLY. A real user has one role and must never see this —
   a switcher implies the other views are reachable. Role is an identity, not
   a preference. See ROLES.md.                                                */

.ws-roleswitch { display: flex; gap: 0; border: var(--ws-border); border-radius: var(--ws-radius); overflow: hidden; }
.ws-roleswitch button {
  display: flex; align-items: center; gap: var(--ws-2);
  background: transparent; border: 0; cursor: pointer;
  padding: var(--ws-2) var(--ws-3);
  color: var(--ws-text-mut);
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  border-right: 1px solid var(--ws-ink-500);
  transition: color var(--ws-ease), background var(--ws-ease);
}
.ws-roleswitch button:last-child { border-right: 0; }
.ws-roleswitch button:hover { color: var(--ws-text); background: var(--ws-ink-700); }
.ws-roleswitch button[aria-pressed="true"] { color: var(--ws-gold); background: var(--ws-ink-700); }
.ws-roleswitch button:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: -2px; }

.ws-rolebar {
  display: flex; align-items: center; gap: var(--ws-4);
  padding: var(--ws-2) var(--ws-6);
  background: var(--ws-ink-900);
  border-bottom: var(--ws-border);
}

/* --- Competition badge ----------------------------------------------------
   A monogram, not a league logo. Real federation and league marks are
   trademarks belonging to those bodies; they are not ours to bundle into a
   deployed build. Licence them per competition, then swap this for the art.  */

.ws-comp {
  display: inline-flex; align-items: center; gap: var(--ws-2);
  min-width: 0;
}
.ws-comp-badge {
  flex: 0 0 auto;
  width: 26px; height: 26px;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--ws-ink-400);
  border-radius: var(--ws-radius);
  background: var(--ws-ink-900);
  font-family: var(--ws-display); font-weight: 700;
  font-size: 9px; letter-spacing: 0.02em;
  color: var(--ws-text-dim);
  text-transform: uppercase;
}
.ws-comp-badge[data-tier="1"], .ws-comp-badge[data-tier="2"] { color: var(--ws-gold); border-color: var(--ws-gold); }

/* --- Footer ---------------------------------------------------------------- */

.ws-footer {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--ws-4); flex-wrap: wrap;
  max-width: 1400px; margin: var(--ws-9) auto 0;
  padding: var(--ws-5) var(--ws-6) calc(var(--ws-6) + env(safe-area-inset-bottom, 0px));
  border-top: var(--ws-border);
}
.ws-footer-link {
  background: none; border: 0; padding: 0; cursor: pointer;
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  color: var(--ws-text-mut); border-bottom: 1px solid transparent;
}
.ws-footer-link:hover { color: var(--ws-text); border-bottom-color: var(--ws-ink-400); }
.ws-footer-link:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 3px; }

/* --- Leave-without-saving confirm (§2) ---------------------------------------
   A small overlay dialog, every width — see confirmLeave()/formIsDirty() in
   the script. Not a phone-only "sheet" (§ mobile pass, below); this guards
   the submission form on desktop too. */
#leave-confirm {
  display: none; position: fixed; inset: 0; z-index: 60;
  align-items: center; justify-content: center;
  padding: var(--ws-4);
  background: rgba(10, 10, 10, 0.6);
}
#leave-confirm:not([hidden]) { display: flex; }
#leave-confirm-panel { max-width: 360px; width: 100%; }

/* --- WS360 rating ---------------------------------------------------------
   Headline number plus the four subscores that produce it. The composite is
   never shown without its decomposition — a number you cannot take apart is
   the thing the spec calls a black box.

   Evidence sits below the rule because it qualifies the rating rather than
   feeding it: thin evidence means "we do not know yet", not "not good".      */

.ws-score {
  background: var(--ws-ink-700);
  border: var(--ws-border);
  border-radius: var(--ws-radius);
  padding: var(--ws-4) var(--ws-5) var(--ws-5);
  min-width: 260px;
}
.ws-score-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--ws-3); }
.ws-score-mark {
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro); color: var(--ws-text-dim);
}
.ws-score-mark { display: block; color: var(--ws-text); }
.ws-score-mark sup { font-size: 0.65em; vertical-align: super; letter-spacing: 0; color: var(--ws-text-mut); }

.ws-score-value {
  font-family: var(--ws-display); font-weight: 700;
  font-size: 4.25rem; line-height: 0.78;
  font-variant-numeric: tabular-nums;
  color: var(--ws-gold);
  display: block; margin-top: var(--ws-2);
}
/* Provisional ratings are never gold. Gold means the evidence is there. */
.ws-score[data-provisional] .ws-score-value { color: var(--ws-text); }
.ws-score-margin { font-size: var(--ws-fs-small); color: var(--ws-text-mut); font-variant-numeric: tabular-nums; }
.ws-score-target { display: block; margin-top: var(--ws-2); }

.ws-score-flag {
  display: inline-block; margin-top: var(--ws-2);
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
  color: var(--ws-flare);
}

.ws-sub { display: flex; flex-direction: column; gap: var(--ws-2); margin-top: var(--ws-4); }
.ws-sub-row { display: grid; grid-template-columns: 1fr 34px; gap: var(--ws-3); align-items: center; }
.ws-sub-row > span:first-child { min-width: 0; }
.ws-sub-label {
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro); color: var(--ws-text-dim);
  display: block; margin-bottom: 3px;
}
.ws-sub-bar { display: block; height: 3px; background: var(--ws-ink-500); position: relative; }
.ws-sub-bar i { position: absolute; inset: 0 auto 0 0; background: var(--ws-text-dim); display: block; }
/* Only a subscore clearing the target band earns gold. */
.ws-sub-bar[data-clears] i { background: var(--ws-gold); }
.ws-sub-num {
  font-family: var(--ws-display); font-weight: 700; font-size: var(--ws-fs-small);
  font-variant-numeric: tabular-nums; text-align: right; color: var(--ws-text);
}
.ws-sub-num[data-null] { color: var(--ws-text-ghost); }

.ws-score-evidence {
  display: flex; align-items: center; gap: var(--ws-2);
  margin-top: var(--ws-4); padding-top: var(--ws-3);
  border-top: 1px solid var(--ws-ink-500);
}

/* Compact form for the roster column. */
.ws-score-mini { display: flex; align-items: baseline; gap: 4px; justify-content: flex-end; }
.ws-score-mini b {
  font-family: var(--ws-display); font-weight: 700; font-size: 1rem;
  font-variant-numeric: tabular-nums; color: var(--ws-gold);
}
.ws-score-mini[data-provisional] b { color: var(--ws-text-dim); }
.ws-score-mini span { font-size: var(--ws-fs-micro); color: var(--ws-text-mut); }

/* --- Observation history --------------------------------------------------
   Every time this player was looked at, what it produced, and what it moved.
   The point is auditability: a rating nobody can trace back to a date, a place
   and a named observer is exactly the black box the spec forbids.            */

.ws-timeline { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.ws-timeline th {
  text-align: left;
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro); color: var(--ws-text-mut);
  padding: var(--ws-2) var(--ws-3); border-bottom: var(--ws-border-strong);
  white-space: nowrap;
}
.ws-timeline td {
  padding: var(--ws-3); border-bottom: 1px solid var(--ws-ink-700);
  font-size: var(--ws-fs-small); vertical-align: top;
}
.ws-timeline tbody tr:hover { background: var(--ws-ink-700); }
.ws-timeline .ws-num { text-align: right; white-space: nowrap; }

/* A marker in the date cell, so the column reads as a spine. */
.ws-tl-date { display: flex; gap: var(--ws-3); align-items: baseline; white-space: nowrap; }
.ws-tl-date::before {
  content: ""; width: 7px; height: 7px; border-radius: var(--ws-radius-pill);
  background: var(--ws-ink-400); flex: 0 0 auto; transform: translateY(-2px);
}
/* Verified live observation is the highest-value kind. Mark it. */
.ws-timeline tr[data-kind="live"] .ws-tl-date::before { background: var(--ws-gold); }
.ws-timeline tr[data-kind="combine"] .ws-tl-date::before { background: var(--ws-gold); }
.ws-timeline tr[data-kind="player"] .ws-tl-date::before { background: transparent; border: 1px solid var(--ws-ink-400); }

.ws-tl-kind {
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro); color: var(--ws-text-dim);
  white-space: nowrap;
}
.ws-delta { font-size: var(--ws-fs-micro); letter-spacing: var(--ws-ls-micro); }
.ws-delta[data-dir="up"]   { color: var(--ws-gold); }
.ws-delta[data-dir="down"] { color: var(--ws-flare); }
.ws-delta[data-dir="flat"] { color: var(--ws-text-mut); }

/* --- Country flag ----------------------------------------------------------
   Regional indicator characters, not image files — 24 territories and
   counting. The one place colour enters a four-colour palette, so it is kept
   small and always paired with the ISO code, which is what actually carries
   the meaning if the glyph does not render (Windows shows the letters).      */

.ws-flag {
  font-size: 0.95em;
  margin-right: var(--ws-2);
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
  line-height: 1;
}

/* --- WS360 badge ----------------------------------------------------------
   The mark and the data visualisation are the same object.

   · The arc is literally the 360 — the composite swept as degrees.
   · Five dots sit on the ring, echoing the parent mark's five, one per
     domain. Gold when that domain clears the target band, dim when it does
     not, hollow when it was never measured.
   · The number sits in the middle, in the display face.

   Hollow means "not measured", the same as everywhere else in the system.
   It is the one circle the brand allows, and it earns it.                    */

.ws-badge { display: block; flex: 0 0 auto; }
.ws-badge-track  { fill: none; stroke: var(--ws-ink-500); stroke-width: 3; }
.ws-badge-arc    { fill: none; stroke: var(--ws-gold); stroke-width: 3; stroke-linecap: butt;
                   transition: stroke-dashoffset var(--ws-ease-slow); }
.ws-badge-num    { font-family: var(--ws-display); font-weight: 700; font-size: 40px;
                   fill: var(--ws-gold); text-anchor: middle; dominant-baseline: central;
                   font-variant-numeric: tabular-nums; }
.ws-badge-cap    { font-family: var(--ws-display); font-weight: 700; font-size: 11px;
                   fill: var(--ws-text-mut); text-anchor: middle; letter-spacing: 1.2px; }
/* The badge's own internal caption text splits the same way the inline
   lockup does (below) — "360" gold, "WS" not — so the chart still reads as
   part of the same identity as the mark beside it. */
.ws-badge-cap-360 { fill: var(--ws-gold); }

/* Dots punch through the ring: the halo stroke is the panel colour. */
.ws-badge-back { fill: var(--ws-ink-700); stroke: none; }
.ws-badge-dot { stroke: none; fill: var(--ws-gold); }
.ws-badge-dot[data-state="under"]  { fill: var(--ws-text-dim); }
.ws-badge-dot[data-state="absent"] { fill: var(--ws-ink-700); stroke: var(--ws-ink-400); stroke-width: 2; }
/* The badge also sits on the well surface in some contexts. */
.ws-panel--well .ws-badge-back { fill: var(--ws-ink-900); }

/* Provisional: nothing in the badge is allowed to be gold. */
.ws-score[data-provisional] .ws-badge-arc { stroke: var(--ws-text-dim); stroke-dasharray: 5 4; }
.ws-score[data-provisional] .ws-badge-num { fill: var(--ws-text); }
.ws-score[data-provisional] .ws-badge-dot { fill: var(--ws-text-dim); }
.ws-score[data-provisional] .ws-badge-dot[data-state="absent"] { fill: var(--ws-ink-700); }

.ws-score-top { display: flex; gap: var(--ws-4); align-items: flex-start; }
.ws-score-meta { min-width: 0; padding-top: var(--ws-2); }

/* --- View-as, top right ---------------------------------------------------- */

.ws-viewas { display: flex; align-items: center; gap: var(--ws-3); margin-left: auto; }
.ws-rolenote {
  margin: 0; padding: var(--ws-2) var(--ws-6);
  background: var(--ws-ink-900); border-bottom: var(--ws-border);
  text-align: right;
}

@media (max-width: 1000px) {
  .ws-viewas .ws-roleswitch button span { display: none; }   /* icons only */
  .ws-viewas > .ws-micro { display: none; }
}
@media (max-width: 700px) {
  .ws-viewas { margin-left: 0; order: 2; }
  .ws-rolenote { text-align: left; padding-inline: var(--ws-4); }
}

/* --- Filter states ---------------------------------------------------------
   A filter that changes nothing is worse than no filter — it teaches people
   the tool is broken. Every chip drives a real query, the count updates, and
   an empty result says so rather than showing an empty box.                  */

.ws-chip--clear { border-style: dashed; color: var(--ws-text-mut); }
.ws-chip--clear:hover { border-color: var(--ws-flare); color: var(--ws-flare); }

.ws-empty {
  margin: 0; padding: var(--ws-7) var(--ws-4);
  text-align: center; color: var(--ws-text-mut);
  font-size: var(--ws-fs-small);
}

/* Not every column is meaningfully sortable — see the WS360 note. */
.ws-table th[data-sort] { cursor: pointer; user-select: none; }
.ws-table th[data-sort]:hover { color: var(--ws-text); }
.ws-table th[data-sort]::after { content: ""; margin-left: 4px; opacity: 0; }
.ws-table th[data-sort][aria-sort="ascending"]::after  { content: "\2191"; opacity: 1; color: var(--ws-gold); }
.ws-table th[data-sort][aria-sort="descending"]::after { content: "\2193"; opacity: 1; color: var(--ws-gold); }
.ws-table th[data-nosort] { cursor: help; }

/* --- Questionnaire --------------------------------------------------------
   "I am not sure" is a real answer, not a failure to give one. The spec
   requires it on every subjective question, so it is styled as a legitimate
   choice — dashed rather than dimmed into looking broken.                    */

.ws-q { padding: var(--ws-5) 0; border-bottom: var(--ws-border); }
.ws-q:last-child { border-bottom: 0; }
.ws-q-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--ws-4); }
.ws-q-prompt { font-size: 1.0625rem; margin: 0; }
.ws-q-hint { white-space: nowrap; }
.ws-q-opts { display: flex; flex-wrap: wrap; gap: var(--ws-2); margin-top: var(--ws-4); }

.ws-opt {
  font-family: var(--ws-body); font-size: var(--ws-fs-small);
  padding: var(--ws-2) var(--ws-4);
  border: var(--ws-border); border-radius: var(--ws-radius);
  background: transparent; color: var(--ws-text-dim);
  cursor: pointer; transition: all var(--ws-ease);
}
.ws-opt:hover { border-color: var(--ws-text-mut); color: var(--ws-text); }
.ws-opt[aria-pressed="true"] { background: var(--ws-gold); border-color: var(--ws-gold); color: var(--ws-ink); font-weight: 600; }
.ws-opt[data-unsure] { border-style: dashed; }
.ws-opt[data-unsure][aria-pressed="true"] { background: transparent; color: var(--ws-text); border-color: var(--ws-text-mut); }
.ws-opt:disabled { opacity: 0.35; cursor: not-allowed; }
.ws-opt:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }

.ws-q[data-answered] .ws-q-prompt::after {
  content: "\2713"; color: var(--ws-gold); margin-left: var(--ws-2); font-size: 0.9em;
}

.ws-progress { display: flex; align-items: center; gap: var(--ws-4); flex-wrap: wrap; }
.ws-progress-bar { flex: 1 1 200px; height: 3px; background: var(--ws-ink-500); min-width: 140px; }
.ws-progress-bar i { display: block; height: 100%; background: var(--ws-gold); transition: width var(--ws-ease); }

/* Quick intake's extract call has no progress events, only a 10-40s wait —
   the bar sweeps instead of filling. See gen_prototype.py's submission view. */
.ws-progress-bar--indeterminate i { width: 40%; animation: ws-intake-sweep 1.1s ease-in-out infinite; }
@keyframes ws-intake-sweep {
  0%   { margin-left: 0%; }
  50%  { margin-left: 60%; }
  100% { margin-left: 0%; }
}

/* --- Quick intake -----------------------------------------------------------
   A field the prefill touched. Outline, not colour alone — the note beside
   it (wired through aria-describedby, see markControl() in the auth/RPC
   block) says which one it is even without colour. Gold/flare match the
   same evidence-vs-gap taxonomy the signal dots use elsewhere.             */
.ws-intake-filled    { outline: 2px solid var(--ws-gold);  outline-offset: 2px; }
.ws-intake-uncertain { outline: 2px solid var(--ws-flare); outline-offset: 2px; }

/* Phone-first: every control in the card clears the 44px touch target the
   rest of this system's smaller chips and links don't need to. */
#intake-card button, #intake-card label.ws-btn {
  min-height: 44px; display: inline-flex; align-items: center; justify-content: center;
}
/* syncSubmissionGate() disables the file input itself (a label has nothing
   to disable), which already stops the camera from opening — this just
   makes that state visible. Browsers without :has() simply keep the label
   at full opacity; the input stays disabled either way. */
#intake-card label.ws-btn:has(+ input:disabled) { opacity: 0.4; cursor: not-allowed; }

/* --- Trajectory ----------------------------------------------------------- */

.ws-traj { display: grid; gap: var(--ws-4); grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.ws-traj-card { border: var(--ws-border); border-radius: var(--ws-radius); padding: var(--ws-4); background: var(--ws-ink-700); }
.ws-traj-dir {
  font-family: var(--ws-display); font-weight: 700;
  font-size: var(--ws-fs-micro); text-transform: uppercase;
  letter-spacing: var(--ws-ls-micro);
}
.ws-traj-dir[data-d="improving"]    { color: var(--ws-gold); }
.ws-traj-dir[data-d="stable"]       { color: var(--ws-gold); }
.ws-traj-dir[data-d="volatile"]     { color: var(--ws-flare); }
.ws-traj-dir[data-d="declining"]    { color: var(--ws-flare); }
.ws-traj-dir[data-d="insufficient"] { color: var(--ws-text-mut); }
/* How many clubs a metric survived is the scout-relevant number. */
.ws-traj-clubs { display: flex; gap: 3px; align-items: center; }
.ws-traj-clubs i { width: 6px; height: 6px; border-radius: var(--ws-radius-pill); background: var(--ws-gold); display: block; }

/* --- Roster on a phone -----------------------------------------------------
   A scout works this list standing in a gym. Twelve columns behind a
   horizontal scrollbar is the wrong shape for that, so below 760px each row
   becomes a card: the six fields that matter for a decision, and the rest
   folded away rather than pushed off-screen.
   Scoped to .ws-roster-table (the /roster screen's own table, gen_prototype.py)
   — a bare .ws-table selector here used to also catch every workflow ledger
   (event roster, team sheet, events/games/sessions lists — workflow_ui.py's
   .ws-wf-table), none of which carry the data-l labels this transform keys
   off, so their cells inherited width:100% from the first rule below with no
   grid-area to land in — overlapping, clipped rows and a shrunk checkbox. See
   .ws-wf-table's own phone treatment further down this file. */

@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) {
  .ws-roster-table thead { display: none; }
  .ws-roster-table, .ws-roster-table tbody, .ws-roster-table tr, .ws-roster-table td { display: block; width: 100%; }

  .ws-roster-table tbody tr {
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr auto;
    grid-template-areas:
      "photo name  score"
      "photo meta  score"
      "photo comp  conf";
    gap: var(--ws-1) var(--ws-3);
    align-items: center;
    padding: var(--ws-4) 0;
    border-bottom: 1px solid var(--ws-ink-700);
  }
  .ws-roster-table tbody tr:hover { background: transparent; }

  .ws-roster-table td { padding: 0; border: 0; white-space: normal; }
  .ws-roster-table td:first-child            { grid-area: photo; }
  .ws-roster-table td[data-l="Player"]       { grid-area: name; font-size: var(--ws-fs-body); }
  .ws-roster-table td[data-l="Role"]         { grid-area: meta; color: var(--ws-text-dim); }
  .ws-roster-table td[data-l="Competition"]  { grid-area: comp; }
  .ws-roster-table td[data-l="WS360"]        { grid-area: score; text-align: right; }
  .ws-roster-table td[data-l="Confidence"]   { grid-area: conf; justify-self: end; }

  /* Folded away, not deleted — the detail view still carries them. */
  .ws-roster-table td[data-l="ID"],
  .ws-roster-table td[data-l="Age"],
  .ws-roster-table td[data-l="Country"],
  .ws-roster-table td[data-l="Position"],
  .ws-roster-table td[data-l="Level"],
  .ws-roster-table td[data-l="Last seen"] { display: none; }

  .ws-roster-table .ws-photo--thumb { width: 44px; }
  .ws-table-wrap:has(.ws-roster-table) { overflow-x: visible; }

  /* The observation timeline keeps its columns and scrolls — it is a ledger,
     and a ledger with rows folded away is not a ledger. */
  .ws-timeline, .ws-timeline thead, .ws-timeline tbody,
  .ws-timeline tr, .ws-timeline td { display: revert; }
  .ws-timeline { display: table; min-width: 680px; }
  .ws-table-wrap:has(.ws-timeline) { overflow-x: auto; }
}

@media (max-width: 760px) {
  .ws-score { min-width: 0; }
  .ws-score-top { flex-wrap: wrap; }
  .ws-shell { padding-inline: var(--ws-4); }
}

/* ============================================================================
   MOBILE PASS — phones, defined as max-width: 640px throughout this file.
   Everything from here down either (a) only takes effect inside that media
   query, or (b) is a component new to this pass that renders nothing until
   gen_prototype.py's markup/JS puts it to use (cards, sheets, the accordion,
   the sticky action bar, the FAB, text clamping). Desktop/tablet
   (≥641px) is unchanged below this point except where a rule is explicitly
   NOT scoped to the media query — paging and text clamping are the same at
   every width by design (see §C/§D of the brief this implements).
   ============================================================================ */

/* --- Roster cards -----------------------------------------------------------
   Same data as the table (rosterRow/rosterCard render the same fields from
   the same esc()'d strings) — the table stays the source of truth and stays
   on desktop; this is a second rendering of it for a single ≥64px tap
   target per player, not a second source of the data.                       */
.ws-roster-cards { display: none; flex-direction: column; gap: var(--ws-3); }
.ws-roster-card {
  display: block; width: 100%; text-align: left;
  background: var(--ws-ink-700); border: var(--ws-border); border-radius: var(--ws-radius);
  padding: var(--ws-4); min-height: 64px; cursor: pointer;
}
.ws-roster-card p { margin: 3px 0 0; }
.ws-roster-card p:first-child { margin-top: 0; }
.ws-roster-card:hover { border-color: var(--ws-text-mut); }
.ws-roster-card:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: -2px; }
/* Thumbnail + text stack, added once photo_url exists (sample_data.py's
   fictional world; Live sends none yet — see .ws-photo--thumb's own
   empty-state dot, which is what the other 55 thin rows keep showing). */
.ws-roster-card-row { display: flex; gap: var(--ws-3); align-items: flex-start; }
.ws-roster-card-body { min-width: 0; flex: 1 1 auto; }
/* A linked name cell with its thumbnail — event roster rows and combine
   entry rows (workflow_ui.py's wfRosterRow/wfCombineEntryRow); both are
   plain data-grid rows with no room for a photo column of their own. */
.ws-wf-name-cell { display: inline-flex; gap: var(--ws-2); align-items: center; }
.ws-wf-name-cell .ws-photo--thumb { width: 32px; flex: 0 0 auto; }
.ws-roster-card-body p:first-child { margin-top: 0; }

/* --- Scout home (/scout) ---------------------------------------------------
   "Start something"'s four cards, the recent-players strip, the event cards
   and each "needs attention" row all reuse .ws-panel/.ws-btn/.ws-grid — these
   are the few genuinely new shapes: a big square action card, a small
   avatar+name chip, and a left-rule attention row (same rule .ws-callout
   already uses elsewhere for "something needs a look"). */
.ws-wf-home-card { min-height: 72px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 2px; text-align: left; white-space: normal; }
.ws-wf-home-card span { font-size: var(--ws-fs-micro); font-weight: 400; opacity: .75; }
/* Home's one big action (Take notes) — full width, thumb-sized. */
.ws-wf-home-take { display: flex; width: 100%; min-height: 64px; align-items: center; justify-content: center; font-size: var(--ws-fs-body); }
.ws-wf-home-players { display: flex; flex-wrap: wrap; gap: var(--ws-3); }
/* Row/card links group several unrelated pieces of text (a title, a team,
   a date) under one clickable target — an underline running the length of
   all of it reads as broken, not as a link, so these three turn it off at
   the container and put it back only on the one phrase that should look
   named/clickable (.ws-wf-home-linktext) via a nested span. */
.ws-wf-home-player { display: inline-flex; align-items: center; gap: var(--ws-2); min-height: 44px; padding: var(--ws-1) var(--ws-2); text-decoration: none; color: var(--ws-text); }
.ws-wf-home-player:hover { color: var(--ws-gold); }
.ws-wf-home-player .ws-photo--thumb { width: 32px; flex: 0 0 auto; }
.ws-wf-home-row { display: flex; align-items: center; justify-content: space-between; gap: var(--ws-3); min-height: 44px; padding: var(--ws-2) 0; border-bottom: var(--ws-border); text-decoration: none; color: var(--ws-text); }
.ws-wf-home-row:last-child { border-bottom: none; }
.ws-wf-home-row:hover .ws-wf-home-linktext { color: var(--ws-gold); }
.ws-wf-home-linktext { text-decoration: underline; text-decoration-color: var(--ws-text-mut); }
.ws-wf-home-attn { border-left: 2px solid var(--ws-flare); padding-left: var(--ws-3); margin: 0 0 var(--ws-3); }
.ws-wf-home-event-card { display: block; text-decoration: none; color: var(--ws-text); }
.ws-wf-home-event-card:hover .ws-wf-home-linktext { color: var(--ws-gold); }
.ws-wf-home-disclosure summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
.ws-wf-home-disclosure summary::-webkit-details-marker { display: none; }
#home-attention-summary::after { content: "\25BE"; margin-left: var(--ws-2); }
#home-attention details[open] > #home-attention-summary::after { content: "\25B4"; }

/* --- Paging — "Show 25 more", both breakpoints (§C) ------------------------- */
.ws-pager { display: flex; align-items: center; justify-content: space-between; gap: var(--ws-4); margin-top: var(--ws-4); flex-wrap: wrap; }

/* --- FAB: add a player from a photo, phones only ---------------------------- */
.ws-fab {
  display: none;
  position: fixed; z-index: 20;
  right: var(--ws-4);
  bottom: calc(var(--ws-4) + env(safe-area-inset-bottom, 0px));
  width: 56px; height: 56px; border-radius: var(--ws-radius-pill);
  background: var(--ws-gold); color: var(--ws-ink); border: 0; cursor: pointer;
  align-items: center; justify-content: center;
  box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45);
}
.ws-fab svg { width: 26px; height: 26px; stroke: currentColor; stroke-width: 7; fill: none; stroke-linecap: square; }
.ws-fab:focus-visible { outline: 2px solid var(--ws-text); outline-offset: 2px; }

/* --- Sheets ------------------------------------------------------------------
   A full-screen dialog on phones; above 640px this is nothing more than the
   plain in-page disclosure it always was (see #search-filters-panel /
   #role-sheet in gen_prototype.py — same `hidden` toggle either way, this
   only changes what "not hidden" looks like). openSheet()/closeSheet() add
   role="dialog"'s behaviour: initial focus, Escape, a Back press, and a
   click on the scrim or the close button all close it; body scroll is
   locked via body.ws-sheet-locked below. */
.ws-sheet-head, .ws-sheet-foot { display: none; }
body.ws-sheet-locked { overflow: hidden; }

/* --- Drawer: a detail of what you're already looking at, not a new screen --
   Right-side panel on desktop, bottom sheet on phones. One generic shell
   (gen_prototype.py's wfOpenDrawer/wfCloseDrawer), reused for the
   evaluation/player/prompt drawers alike — reuses body.ws-sheet-locked
   above rather than a second scroll-lock class. */
.ws-drawer { position: fixed; inset: 0; z-index: 60; }
.ws-drawer-scrim { position: absolute; inset: 0; background: rgba(13, 13, 13, 0.72); }
.ws-drawer-panel {
  position: absolute; top: 0; right: 0; height: 100%;
  width: min(620px, 92vw); max-width: 92vw;
  background: var(--ws-ink-800); border-left: var(--ws-border);
  display: flex; flex-direction: column;
  transform: translateX(100%); transition: transform var(--ws-ease);
  overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.ws-drawer:not([hidden]) .ws-drawer-panel { transform: translateX(0); }
.ws-drawer-header {
  position: sticky; top: 0; z-index: 1; background: var(--ws-ink-800);
  display: flex; align-items: flex-start; justify-content: space-between; gap: var(--ws-4);
  padding: var(--ws-5) var(--ws-6); border-bottom: var(--ws-border);
}
.ws-drawer-close {
  min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center;
  background: none; border: 1px solid var(--ws-ink-400); border-radius: var(--ws-radius);
  color: var(--ws-text); cursor: pointer; font-size: 1.4rem; line-height: 1; flex: 0 0 auto;
}
.ws-drawer-close:hover { color: var(--ws-gold); border-color: var(--ws-gold); }
.ws-drawer-close:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }
.ws-drawer-body { padding: var(--ws-6); flex: 1 1 auto; }
/* The evaluation view, moved wholesale into #ws-drawer-mount while the
   drawer is open (see wfOpenEvalDrawer) — its own .ws-shell width/padding
   would fight the drawer's, so both are neutralised only in that context. */
.ws-drawer-body .ws-shell { max-width: none; padding: 0; margin: 0; }
/* Container-driven, not viewport-driven: the panel is always narrow
   (min(620px, 92vw), see .ws-drawer-panel above) no matter the surrounding
   viewport, so the record field grid / WS360 part rows and metric tables /
   measurements / data-rights grids the full-page player shell (and the
   evaluation view's own compact WS360 block, #eval-ws360) share with this
   drawer never hit the 900px/640px viewport queries above at desktop
   widths — labels and values overlap instead of collapsing. [data-narrow]
   (set once, permanently, on #ws-drawer — see workflow_ui.py's
   WORKFLOW_SB_HTML) collapses every one of those grids single-column
   unconditionally, the same shape those viewport queries already give
   phones, so nothing here ever needs to widen the drawer to fit them. */
.ws-drawer[data-narrow] .ws-grid--2,
.ws-drawer[data-narrow] .ws-grid--3,
.ws-drawer[data-narrow] .ws-grid--4 { grid-template-columns: minmax(0, 1fr); }
.ws-drawer[data-narrow] .ws-360-part-toggle,
.ws-drawer[data-narrow] .ws-360-part-toggle--static { grid-template-columns: 1fr; gap: 2px; }
@media (max-width: 600px) {
  .ws-drawer-panel {
    top: auto; right: 0; left: 0; height: 92vh; width: 100%; max-width: 100%;
    border-left: none; border-top: var(--ws-border); border-radius: var(--ws-radius) var(--ws-radius) 0 0;
    transform: translateY(100%);
  }
  .ws-drawer:not([hidden]) .ws-drawer-panel { transform: translateY(0); }
}
@media print { .ws-drawer { display: none !important; } }
/* The chips-and-button summary is a phone-only stand-in for the plain
   filter box + checkgroup desktop still shows inline — see gen_prototype.py's
   #role-summary/#role-sheet-open and syncRoleSheetForViewport(). */
#role-summary, #role-sheet-open { display: none; }

/* --- Text clamp — long report text, both breakpoints (§D) ------------------- */
.ws-clamp {
  display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical;
  overflow: hidden;
}
.ws-clamp.is-expanded { display: block; -webkit-line-clamp: unset; overflow: visible; }
.ws-clamp-toggle { margin-top: var(--ws-2); }

/* --- Sticky bottom action bar, phones only — applied straight to the
   submission form's existing Save-draft/Save-player row (.ws-actionbar), not
   a duplicate pair of buttons, so #sub-save/#sub-save-draft stay the one
   real pair of controls at every width. --------------------------------- */

/* --- Private intel disclosure (record view) ---------------------------------- */
.ws-private-toggle { display: none; }

/* The Scout/Player preview switch already lives in the header (.ws-viewas) —
   no relocation needed, just the 44px floor the phone media query below
   already gives every .ws-roleswitch button. */

/* The accordion chevron and "n of m filled" count are added to every section
   head unconditionally (see initFormAccordion()/updateAccordionCounts() in
   gen_prototype.py) because the collapsing itself is what is phone-only —
   these two are hidden above 640px so desktop's section heads render
   exactly as before. */
.ws-accordion-chevron, .ws-accordion-count { display: none; }

/* --- Capture-first intake (§1): default-hidden, every width ------------------
   These are new elements with no other styling, so without this they would
   render at their plain browser default (visible) above 640px — unlike the
   rules just above, which hide an existing, already-styled component, these
   need their own explicit "not shown unless the phone media query below
   turns it back on" floor. Desktop never sets data-intake-step away from its
   "form" default, so none of these three (or #form-use-intake-link/
   #intake-review-edit, hidden the same way further down) ever appear there. */
#capture-heading, #capture-actions, #capture-type-instead { display: none; }
#form-use-intake-link, #intake-review-edit { display: none; }

@media (max-width: 640px), (pointer: coarse) and (max-height: 500px) {
  /* The max-width alone only ever caught a phone held upright — turned
     sideways it's ~844px wide and fell through to every rule below, back to
     13px inputs (iOS zooms the page), sub-44px targets and the full desktop
     form. The second query is landscape's own floor: (pointer: coarse) is
     true only on touch hardware (a mouse-driven desktop never matches, even
     one resized to a short window), and max-height:500px is short enough to
     catch a phone turned sideways without catching a touchscreen laptop. */

  /* Body text ≥14px everywhere (§E) — every consumer of these two tokens
     (labels, chips, table/roster cells, nav, badges, the footer) grows by
     the same amount rather than each getting its own override. The roster
     alone measured 344 instances under 14px before this. */
  :root { --ws-fs-micro: 0.875rem; --ws-fs-small: 0.9375rem; }

  /* Inputs never below 16px (iOS zooms the page on focus below that) and
     never under the 44px tap-target floor (§B). */
  .ws-input { font-size: 16px; min-height: 44px; }
  textarea.ws-input { min-height: 88px; }
  .ws-checkbox input[type="checkbox"] { width: 20px; height: 20px; }

  /* 44×44 minimum on every interactive control (§E), buttons vertically
     centred rather than just taller. Chips/checks keep their compact look
     but grow to the floor; a segmented control's internal divider (the role
     switch) is exempt from the 8px-apart rule by convention — its buttons
     are meant to touch. */
  /* .ws-nav button is exempt from this list — its own rules (above) already
     cover the 44/48px floor at every width, left-aligned with its icon, not
     centred like these. */
  .ws-btn, .ws-chip, .ws-check, .ws-opt, .ws-footer-link,
  .ws-roleswitch button, .ws-fab {
    min-height: 44px;
    display: inline-flex; align-items: center; justify-content: center;
  }
  /* .ws-footer-link carries no padding of its own (it reads as a bare
     underlined word, not a button) — short labels like Reset measured
     under the 44px floor on width alone. */
  .ws-footer-link { min-width: 44px; padding-inline: var(--ws-2); }
  .ws-checkbox { min-height: 44px; }
  .ws-checkgroup { gap: var(--ws-3); }
  a[href] { min-height: 44px; display: inline-flex; align-items: center; }

  /* --- Roster: cards replace the table, sticky search, FAB ------------------
     Scoped to #view-roster on purpose — .ws-table-wrap is a generic wrapper
     used all over the scouting-workflow screens (event roster, team sheet,
     combine entries, reference bands...), none of which have a card
     alternative to fall back to; a bare `.ws-table-wrap { display: none; }`
     here silently hid every one of those tables on a phone, roster's own
     card swap included them all by accident. */
  #view-roster .ws-table-wrap { display: none; }
  .ws-roster-cards { display: flex; }
  #roster-search-panel, #roster-legacy-filters {
    position: sticky; top: var(--ws-topbar-h, 0px); z-index: 5;
    background: var(--ws-ink-800); padding-top: var(--ws-3); margin-left: calc(var(--ws-4) * -1);
    margin-right: calc(var(--ws-4) * -1); padding-left: var(--ws-4); padding-right: var(--ws-4);
  }
  .ws-fab { display: inline-flex; }

  /* Filters becomes a full-screen sheet — the same `hidden` toggle as
     desktop's disclosure, just a different "shown" layout (see the
     .ws-sheet comment above). A sticky apply/clear bar sits at the bottom;
     #search-filters-apply is added by gen_prototype.py only for phones. */
  #search-filters-panel:not([hidden]) {
    position: fixed; inset: 0; z-index: 50;
    background: var(--ws-ink-800); overflow-y: auto;
    padding: calc(var(--ws-4) + env(safe-area-inset-top, 0px)) var(--ws-4) calc(88px + env(safe-area-inset-bottom, 0px));
    margin: 0; border: 0;
  }
  #search-filters-panel .ws-sheet-head {
    display: flex; align-items: center; justify-content: space-between;
    margin: calc(var(--ws-4) * -1) calc(var(--ws-4) * -1) var(--ws-4);
    padding: var(--ws-4); border-bottom: var(--ws-border);
    position: sticky; top: calc(var(--ws-4) * -1); background: var(--ws-ink-800); z-index: 1;
  }
  #search-filters-panel .ws-sheet-foot {
    display: flex; gap: var(--ws-3);
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 51;
    padding: var(--ws-3) var(--ws-4) calc(var(--ws-3) + env(safe-area-inset-bottom, 0px));
    background: var(--ws-ink-800); border-top: var(--ws-border);
  }
  #search-filters-panel .ws-sheet-foot .ws-btn { flex: 1; }

  /* --- Role picker: chips + "Choose roles" opening the same sheet treatment.
     The roster's own role filter lives inside #search-filters-panel above,
     so it already gets the sheet treatment as part of that panel — this
     block is the submission form's dedicated Roles field only. */
  .ws-rolerow { display: flex; flex-wrap: wrap; gap: var(--ws-2); margin-bottom: var(--ws-3); }
  #role-sheet-open { display: inline-flex; }
  #role-sheet:not([hidden]) {
    position: fixed; inset: 0; z-index: 50;
    background: var(--ws-ink-800); overflow-y: auto;
    padding: calc(var(--ws-4) + env(safe-area-inset-top, 0px)) var(--ws-4) calc(88px + env(safe-area-inset-bottom, 0px));
    margin: 0; border: 0;
  }
  #role-sheet .ws-sheet-head {
    display: flex; align-items: center; gap: var(--ws-3);
    margin: calc(var(--ws-4) * -1) calc(var(--ws-4) * -1) var(--ws-4);
    padding: var(--ws-4); border-bottom: var(--ws-border);
    position: sticky; top: calc(var(--ws-4) * -1); background: var(--ws-ink-800); z-index: 1;
  }
  #role-sheet .ws-sheet-head .ws-input { flex: 1; margin: 0; }
  #role-sheet .ws-sheet-foot {
    display: flex;
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 51;
    padding: var(--ws-3) var(--ws-4) calc(var(--ws-3) + env(safe-area-inset-bottom, 0px));
    background: var(--ws-ink-800); border-top: var(--ws-border);
  }
  #role-sheet .ws-sheet-foot .ws-btn { flex: 1; }
  #role-sheet .ws-check { min-height: 48px; width: 100%; justify-content: flex-start; }
  #role-sheet .ws-checkgroup { flex-direction: column; }

  /* --- Form: accordion sections -----------------------------------------------
     No wrapper element around "everything after the heading" — collapsing
     hides every direct child of the section except .ws-section-head itself,
     the same pattern the player record's accordion (below) uses. */
  #submission-form > section {
    padding: 0; overflow: hidden;
  }
  #submission-form > section > .ws-section-head {
    display: flex; width: 100%; min-height: 48px;
    align-items: center; justify-content: space-between;
    background: none; border: 0; border-bottom: var(--ws-border);
    padding: var(--ws-4); margin: 0 0 var(--ws-4); cursor: pointer; color: var(--ws-text);
    font-family: var(--ws-body); text-align: left;
  }
  #submission-form > section > *:not(.ws-section-head):not([data-section-head]) {
    margin-left: var(--ws-4); margin-right: var(--ws-4);
  }
  #submission-form > section > *:last-child { margin-bottom: var(--ws-4); }
  #submission-form > section[data-collapsed] > *:not(.ws-section-head):not([data-section-head]) { display: none; }
  .ws-accordion-chevron, .ws-accordion-count { display: inline-block; }
  #submission-form > section .ws-accordion-chevron { transition: transform var(--ws-ease); }
  #submission-form > section[data-collapsed] .ws-accordion-chevron { transform: rotate(-90deg); }

  /* --- Form: sticky bottom action bar ----------------------------------------- */
  /* Fixed to the very bottom of the screen (the tab bar it used to stack
     above is retired — see the left sidebar/drawer nav further down). The
     form's own bottom padding covers the bar's own height + safe-area so the
     last field is never hidden behind it. */
  #submission-form { padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px)); }
  .ws-actionbar {
    display: flex; gap: var(--ws-3);
    position: fixed; left: 0; right: 0; bottom: env(safe-area-inset-bottom, 0px); z-index: 30;
    padding: var(--ws-3) var(--ws-4);
    background: var(--ws-ink-800); border-top: var(--ws-border);
  }
  .ws-actionbar .ws-btn { flex: 1; min-height: 48px; }
  #view-submission.view { padding-bottom: 0; }

  /* --- Capture-first intake: capture / review / form steps -------------------
     One attribute on the view root (#view-submission[data-intake-step]) drives
     all of it — see setIntakeStep() in the script. Desktop never sets this
     attribute away from its markup default ("form"), so none of this ever
     applies above 640px. */

  /* capture: every form section but the Quick intake card, plus the sticky
     Save bar, are off screen — see the brief's "≤1.2 screens" budget.
     .ws-actionbar needs !important: it carries an inline style="display:flex"
     for its desktop (non-sticky) layout, which otherwise wins over any
     selector no matter how specific — same reasoning as the global [hidden]
     rule at the top of this file. The view's own heading stays in capture
     (it sits above the One player / A whole team choice, #sub-mode) but
     loses its subtitle; #capture-heading, which used to stand in for it
     inside the card, is not shown. Review drops the heading and the
     choice altogether. */
  #view-submission[data-intake-step="capture"] #submission-form > section:not(#intake-card) {
    display: none;
  }
  #view-submission[data-intake-step="capture"] .ws-actionbar {
    display: none !important;
  }
  #view-submission[data-intake-step="capture"] #submission-view-sub,
  #view-submission[data-intake-step="review"] #submission-view-head,
  #view-submission[data-intake-step="review"] #sub-mode {
    display: none;
  }
  /* The global footer sits below every view, always — reclaimed here too,
     same screen-budget reasoning as the rest of capture/review. */
  body:has(#view-submission[data-intake-step="capture"]:not([hidden])) .ws-footer,
  body:has(#view-submission[data-intake-step="capture"]:not([hidden])) #role-note,
  body:has(#view-submission[data-intake-step="review"]:not([hidden])) .ws-footer,
  body:has(#view-submission[data-intake-step="review"]:not([hidden])) #role-note {
    display: none;
  }
  /* No sticky Save bar to clear space for in this step (it's hidden above),
     so the form's usual 88px reservation for it is wasted space here. */
  #view-submission[data-intake-step="capture"] #submission-form {
    padding-bottom: var(--ws-5);
  }
  /* form: the card collapses entirely, replaced by one quiet link back into
     capture (#form-use-intake-link is a sibling of the card, not inside it). */
  #view-submission[data-intake-step="form"] #intake-card {
    display: none;
  }
  /* review: only the summary (now doubling as the review header/actions)
     stays inside the card; heading, actions, method, panels, Fill, the
     privacy line, progress and any error are all capture-only chrome. */
  #view-submission[data-intake-step="review"] #intake-card > *:not(#intake-summary) {
    display: none;
  }

  #view-submission[data-intake-step="capture"] #intake-card-head { display: none; }
  /* padding-top compensates .ws-display--m's tight (0.78) line-height, whose
     cap-height otherwise pokes above the line box and visually overlaps
     .ws-panel's own top edge — not an issue where this same class is used
     inside .ws-section-head (more breathing room above it there). */
  #view-submission[data-intake-step="capture"] #capture-actions {
    display: flex; flex-direction: column; gap: var(--ws-2); margin-bottom: 0;
  }
  /* Fill: hidden, not merely disabled, until there is a page or some text —
     [disabled] already tracks exactly that (syncIntakeFillEnabled(), the
     quick-intake block); capture just also hides on it, so the button
     doesn't sit there inert. Once it appears it is the primary action (see
     the :has() rule below) and pulls in close to the panels above it. */
  #view-submission[data-intake-step="capture"] #intake-fill[disabled] {
    display: none;
  }
  #view-submission[data-intake-step="capture"] #intake-fill {
    margin-top: var(--ws-3) !important;
  }
  /* "Type it in instead" — last, full-width, quiet, but still a real target. */
  #view-submission[data-intake-step="capture"] #capture-type-instead {
    display: flex; align-items: center; justify-content: center;
    width: 100%; min-height: 44px; margin-top: var(--ws-3);
  }
  /* #intake-card-prefixed: needs to out-specificity "#intake-card button,
     #intake-card label.ws-btn" (above, 44px, unconditional, centred) — this
     is the brief's own ≥72px, left-aligned treatment for these two. */
  #intake-card .ws-capture-action {
    display: flex; align-items: center; gap: var(--ws-4);
    width: 100%; min-height: 72px;
    background: var(--ws-ink-700); border: var(--ws-border); border-radius: var(--ws-radius);
    padding: 0 var(--ws-4); font-family: var(--ws-body); font-size: 1rem;
    color: var(--ws-text); text-align: left; cursor: pointer;
    justify-content: flex-start;
  }
  /* Icon at a fixed left inset, label next to it, chevron pinned to the
     right edge — identical for both actions regardless of tag (label vs
     button). */
  .ws-capture-action svg:not(.ws-capture-chevron) {
    width: 28px; height: 28px; flex-shrink: 0;
    stroke: currentColor; stroke-width: 7; fill: none; stroke-linecap: square;
  }
  .ws-capture-action span { flex: 1; }
  .ws-capture-chevron {
    width: 18px; height: 18px; flex-shrink: 0; margin-left: var(--ws-2);
    stroke: currentColor; stroke-width: 9; fill: none; stroke-linecap: square;
    opacity: 0.6;
  }
  /* One primary per screen (there is no Save bar in this step): the photo
     action starts as it, gold like .ws-btn--primary; the moment Fill can
     appear (same [disabled] signal), Fill takes the primary treatment over
     and the photo action drops back to its ordinary secondary look. */
  #view-submission[data-intake-step="capture"] .ws-capture-action--primary {
    background: var(--ws-gold); border-color: var(--ws-gold); color: var(--ws-ink);
  }
  #view-submission[data-intake-step="capture"]:has(#intake-fill:not([disabled])) .ws-capture-action--primary {
    background: var(--ws-ink-700); border-color: var(--ws-ink-400); color: var(--ws-text);
  }
  #view-submission[data-intake-step="capture"]:has(#intake-fill:not([disabled])) #intake-fill {
    background: var(--ws-gold); border-color: var(--ws-gold); color: var(--ws-ink);
  }
  /* The original camera label still does the actual work (renderThumbs()
     toggles it) but #capture-photo-action is the one visible trigger for it
     on this step. */
  #view-submission[data-intake-step="capture"] #intake-photo-label { display: none; }
  /* The Method tabs are the desktop-only way to switch panels; capture's two
     big actions replace them here. */
  #view-submission[data-intake-step="capture"] #intake-method { display: none; }

  #view-submission[data-intake-step="form"] #form-use-intake-link {
    display: inline-flex; margin: var(--ws-4) 0;
  }

  #view-submission[data-intake-step="review"] #intake-review-edit { display: inline-flex; }
  #view-submission[data-intake-step="review"] #intake-summary-dismiss { display: none; }
  #view-submission[data-intake-step="review"] #intake-card,
  #view-submission[data-intake-step="review"] #intake-summary {
    margin-top: 0;
  }
  #view-submission[data-intake-step="review"] #intake-summary {
    padding: var(--ws-3) !important;
  }
  #view-submission[data-intake-step="review"] #intake-summary > * {
    margin-top: 4px !important;
  }
  #view-submission[data-intake-step="review"] #intake-notes-list {
    padding-left: var(--ws-4);
  }
  #view-submission[data-intake-step="review"] #intake-notes-list li {
    line-height: 1.3;
  }
  #view-submission[data-intake-step="review"] #intake-notes-list li + li {
    margin-top: 2px;
  }
  /* Clear/Add more details: same two real buttons, just kept to one row
     (each sharing the width) instead of wrapping to two. */
  #view-submission[data-intake-step="review"] #intake-summary > div:last-child {
    flex-wrap: nowrap;
  }
  #view-submission[data-intake-step="review"] #intake-summary > div:last-child > button {
    flex: 1;
  }
  /* Force-open, per the brief: review ignores the collapsed state of any
     section it shows (setSectionOpen() already removes [data-collapsed] on
     entering review — this just keeps the chevron/count row out of the way
     since there is nothing left to collapse). Every section head shrinks and
     every field wrapper's own "Filled — check this." note is dropped — the
     whole screen already says that once, at the top — to help hold review's
     screen budget without touching a field's own size (still a real,
     44px-tall, 16px-font control either way). */
  #view-submission[data-intake-step="review"] #submission-form > section .ws-accordion-head {
    cursor: default; padding: var(--ws-1) var(--ws-4);
  }
  #view-submission[data-intake-step="review"] #submission-form > section .ws-accordion-chevron {
    display: none; /* force-open, nothing left to click — see the comment above */
  }
  #view-submission[data-intake-step="review"] #submission-form > section {
    margin-bottom: var(--ws-2) !important;
  }
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] {
    margin-bottom: 0;
  }
  #view-submission[data-intake-step="review"] #submission-form .ws-checkgroup {
    gap: var(--ws-2);
  }
  #view-submission[data-intake-step="review"] #submission-form .ws-grid {
    gap: var(--ws-1) var(--ws-2);
    /* Two columns instead of the phone default of one — review already
       shows only the filled fields, so halving the row count matters more
       here than the single-column floor the rest of the form uses. */
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] p[id^="mark-"] {
    display: none;
  }
  /* Field-level helper copy (character counts, "Low / mid / high major",
     "+ Add second nationality") is redundant once review is only showing
     what was already filled in — same "empty space is the enemy" reasoning
     as the marks above. */
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] p.ws-micro,
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] #add-nat2 {
    display: none;
  }
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] label {
    margin-bottom: 2px !important;
  }
  #view-submission[data-intake-step="review"] #intake-summary {
    margin-top: 0 !important;
  }
  /* Empty space is the enemy here — every field wrapper's own side margins
     shrink a step further than the rest of the form's. */
  #view-submission[data-intake-step="review"] #submission-form > section > *:not(.ws-section-head):not([data-section-head]) {
    margin-left: var(--ws-3); margin-right: var(--ws-3);
  }
  #view-submission[data-intake-step="review"] #submission-form > section > *:last-child {
    margin-bottom: var(--ws-3);
  }

  /* --- Review: multi-line fields collapse to 3 lines, grow on focus ---------
     autosizeReviewTextarea()/the focusin-input-focusout trio (quick-intake
     block) set/clear an inline height and this class; the fixed height +
     overflow:hidden below is what "3 lines" actually means when nothing has
     focus. A field is never shrunk below a real, full-size, 44px/16px-font
     control — only its COLLAPSED resting height changes. */
  #view-submission[data-intake-step="review"] #submission-form [data-field-for] {
    position: relative;
  }
  #view-submission[data-intake-step="review"] #submission-form textarea.ws-input {
    height: 4.4em; overflow: hidden; min-height: 0;
  }
  #view-submission[data-intake-step="review"] #submission-form textarea.ws-input.ws-review-expanded {
    height: auto; overflow: visible;
  }
  /* The fade reads as "more below" — suppressed the moment a field expands,
     and colour-matched to Private intel's darker well background. */
  #view-submission[data-intake-step="review"] #submission-form [data-field-for]:has(textarea.ws-input:not(.ws-review-expanded))::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 22px;
    background: linear-gradient(to bottom, transparent, var(--ws-ink-700));
    pointer-events: none;
  }
  #view-submission[data-intake-step="review"] .ws-panel--well [data-field-for]:has(textarea.ws-input:not(.ws-review-expanded))::after {
    background: linear-gradient(to bottom, transparent, var(--ws-ink-900));
  }

  /* --- Player record: collapsible sections, private intel closed ------------- */
  #view-record > section, #view-record > .record-head {
    padding: 0; overflow: hidden;
  }
  #view-record > section > .ws-section-head {
    display: flex; width: 100%; min-height: 48px;
    align-items: center; justify-content: space-between;
    cursor: pointer; padding: var(--ws-4) 0;
  }
  #view-record > section[data-collapsed] > *:not(.ws-section-head) { display: none; }
  #view-record > section .ws-accordion-chevron { transition: transform var(--ws-ease); }
  #view-record > section[data-collapsed] .ws-accordion-chevron { transform: rotate(-90deg); }
  .ws-private-toggle {
    display: flex; align-items: center; justify-content: space-between; gap: var(--ws-4);
    width: 100%; min-height: 48px;
  }
  #record-private[data-closed] > *:not(.ws-private-toggle-wrap) { display: none; }

  /* --- Event detail: one collapsible team per section ------------------------
     Each team's roster is a full table — on a phone, two or three of them
     back to back is most of the screen's own scroll (wfInitEventRosterAccordion(),
     workflow_ui.py). Same pattern as the player record above: the panel
     keeps its own padding (it's a visible card, unlike a plain section), only
     its non-head children hide while collapsed. */
  #event-rosters > .ws-panel > .ws-section-head { cursor: pointer; }
  #event-rosters > .ws-panel[data-collapsed] > *:not(.ws-section-head) { display: none; }
  #event-rosters > .ws-panel .ws-accordion-chevron { transition: transform var(--ws-ease); }
  #event-rosters > .ws-panel[data-collapsed] .ws-accordion-chevron { transform: rotate(-90deg); }

  /* Same again for the Statistics section's own one-panel-per-team layout
     (wfInitEventStatsAccordion(), workflow_ui.py) — a team sheet is a full
     stat line per player, the other half of the same "34 screens" page. */
  #event-stats-body > .ws-panel > .ws-section-head { cursor: pointer; }
  #event-stats-body > .ws-panel[data-collapsed] > *:not(.ws-section-head) { display: none; }
  #event-stats-body > .ws-panel .ws-accordion-chevron { transition: transform var(--ws-ease); }
  #event-stats-body > .ws-panel[data-collapsed] .ws-accordion-chevron { transform: rotate(-90deg); }

  /* Standalone build only (see build.py) — the footer's "Design system"
     link only carries this class there; the artifact build's footer link
     is unaffected. Not worth a tap on a courtside phone. */
  .ws-footer-link--gallery { display: none; }

  /* Every screen below the topbar gets the same top offset the sticky
     search bar uses, so nothing here needs its own special case. */
  .ws-topbar { flex-wrap: nowrap; }
  .ws-viewas { margin-left: 0; }
}

/* ============================================================================
   WORKFLOW — events / event / notes screens (scout role), new in this pass.
   Every class below is prefixed .ws-wf- and built only from tokens.css — no
   new colours, nothing hard-coded. See workflow_ui.py. Everywhere else in
   this file already gives .ws-btn/.ws-chip/.ws-input/etc. their 44px floor
   (the phone-width pass above), so these screens reuse those classes rather
   than redefining touch targets here.
   ============================================================================ */

/* These are plain ledgers (teams, games, roster rows, team sheets), not
   player cards — the roster-specific "table becomes a card" transform above
   is scoped to .ws-roster-table and never reaches them. Their own phone
   treatment: scroll sideways rather than squeeze, with the leading column
   (jersey number / date / title) pinned so a scrolled-right row still reads
   against who or what it is. */
@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) {
  .ws-wf-table { display: table; min-width: 560px; }
  .ws-table-wrap:has(.ws-wf-table) { overflow-x: auto; -webkit-overflow-scrolling: touch; }

  .ws-wf-table th:first-child, .ws-wf-table td:first-child {
    position: sticky; left: 0; z-index: 1;
    background: var(--ws-ink-800); box-shadow: 1px 0 0 var(--ws-ink-700);
  }
  .ws-wf-table tbody tr:hover td:first-child { background: var(--ws-ink-700); }

  /* The statistics line is the one cell whose content is long enough to
     drag the whole row wide on its own (schema/024, wfTeamTotalsLine/
     p.stat_line) — a max-width keeps it wrapping inside its own column
     instead of stretching the table (and clipping) to fit one long line.
     Always a <pre> (workflow_ui.py's renderTeamStatsPanel/wfRosterRow),
     never the class straight on a <td> — a table-cell's own max-width is a
     sizing suggestion the auto-layout column width can still override,
     where a block element's is a hard cap. white-space is repeated here
     only as a floor against .ws-table td's unscoped white-space:nowrap
     (two classes + an element) outranking .ws-wf-statline-text alone. */
  .ws-wf-table .ws-wf-statline-text { max-width: 220px; white-space: pre-wrap; }

  /* The event roster's starter checkbox (workflow_ui.py's wfRosterRow) is a
     bare <input>, not a .ws-checkbox — the only control on these ledgers
     that fell under the 44px floor other screens already get. */
  .ws-wf-table td:has(> input[type="checkbox"]) { min-width: 44px; text-align: center; }
  .ws-wf-table input[type="checkbox"] { width: 24px; height: 24px; }
}

/* --- Event roster: inline "Link" search results ---------------------------- */
.ws-wf-linkresults { display: flex; flex-direction: column; gap: var(--ws-1); margin-top: var(--ws-2); max-width: 260px; }

/* --- Event page: rows, folded sections, phone columns ----------------------
   A saved roster row opens its evaluation on a tap anywhere outside its
   inputs (workflow_ui.py's event-rosters click handler). Games and Note
   sessions fold behind a summary line. An empty, expanded team shows only
   the empty state's own primary Import roster, not the head's as well. */
.ws-wf-row-link { cursor: pointer; }
.ws-wf-col-phone { display: none; }
.ws-wf-disclosure > summary {
  list-style: none; display: flex; align-items: center; justify-content: space-between;
  gap: var(--ws-3); min-height: 44px; cursor: pointer;
}
.ws-wf-disclosure > summary::-webkit-details-marker { display: none; }
.ws-wf-disclosure > summary h2 { margin: 0; }
.ws-wf-disclosure-chevron { transition: transform var(--ws-ease); }
.ws-wf-disclosure:not([open]) .ws-wf-disclosure-chevron { transform: rotate(-90deg); }
#event-rosters > .ws-panel:not([data-collapsed]):has([data-wf-roster-empty]) [data-wf-head-import] { display: none; }

/* --- Add players: One player / A whole team ------------------------------- */
.ws-sub-mode { display: flex; gap: var(--ws-2); margin-bottom: var(--ws-5); }
.ws-sub-mode .ws-chip { flex: 1 1 0; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
#view-submission[data-sub-mode="team"] #submission-form,
#view-submission[data-sub-mode="team"] #sub-alert,
#view-submission[data-sub-mode="team"] #submission-view-sub { display: none !important; }

/* Phones: the events list is cards (#events-cards, .ws-roster-cards), and an
   event roster shows only # · Name · Notes · › — the row itself is the way
   into everything else. */
@media (max-width: 640px), (pointer: coarse) and (max-height: 500px) {
  #events-table-wrap { display: none; }
  .ws-wf-roster-table.ws-wf-table { min-width: 0; width: 100%; }
  .ws-wf-roster-table .ws-wf-col-wide { display: none; }
  .ws-wf-roster-table .ws-wf-col-phone { display: inline; }
  .ws-wf-roster-table th, .ws-wf-roster-table td { padding-left: var(--ws-2); padding-right: var(--ws-2); }
  .ws-wf-roster-table input[data-f="jersey_number"] { width: 52px !important; }
  .ws-wf-roster-table { table-layout: fixed; }
  .ws-wf-roster-table th:first-child { width: 68px; }
  .ws-wf-roster-table th.ws-num { width: 60px; }
  .ws-wf-roster-table th:last-child { width: 56px; }
  .ws-wf-roster-table td[data-name-cell] { white-space: normal; }
  [data-wf-remove-slot]:empty { display: none; }
  .ws-wf-roster-table td[data-name-cell] .ws-input { width: 100%; min-width: 0; }
  .ws-wf-roster-table .ws-btn { padding-left: var(--ws-2); padding-right: var(--ws-2); min-width: 44px; }
  /* A team's head: name (and chevron) on one line, its buttons on the next. */
  #event-rosters > .ws-panel > .ws-section-head { flex-wrap: wrap; }
  #event-rosters > .ws-panel > .ws-section-head > div { order: 3; flex: 1 1 100%; }
  #event-rosters > .ws-panel > .ws-section-head > h3 { flex: 1 1 0; min-width: 0; }
  #event-rosters > .ws-panel > .ws-section-head > div { flex-wrap: nowrap !important; }
  #event-rosters > .ws-panel > .ws-section-head .ws-btn { flex: 1 1 0; white-space: nowrap; padding-left: var(--ws-2); padding-right: var(--ws-2); }
}

/* --- Notes: phone-first capture --------------------------------------------
   One tall textarea above a horizontally-scrolling number strip. Sized
   against the viewport (wfFitNotesTextarea(), workflow_ui.py's WORKFLOW_JS)
   so the on-screen keyboard never covers the Save button or the strip. */
.ws-wf-notes-view { display: flex; flex-direction: column; }
.ws-wf-notes-header { display: flex; flex-direction: column; gap: var(--ws-3); margin-bottom: var(--ws-3); }
.ws-wf-notes-header-row { display: flex; gap: var(--ws-3); flex-wrap: wrap; align-items: center; }
.ws-wf-notes-header-row .ws-input { flex: 1 1 160px; min-width: 0; }
/* The event/team/game summary line: reads as a line of text, opens the selects. */
.ws-wf-notes-context-toggle { display: flex; width: 100%; justify-content: flex-start; text-align: left; text-transform: none; letter-spacing: normal; font-size: var(--ws-fs-body); }

/* Dictation — sits above the textarea (workflow_ui.py's WORKFLOW_VIEWS_HTML,
   notes view). ws-wf-mic-btn is deliberately bigger than the system's usual
   44px minimum: the mic is the one control a scout has to hit one-handed,
   without looking down. The dot is otherwise only ever a circle in the
   WS360 badge (tokens.css: "the dot is the only circle") — same allowance
   here, same reason: a live/attention signal, not chrome. */
.ws-wf-dictate-row { display: flex; align-items: center; gap: var(--ws-3); margin-bottom: var(--ws-3); }
.ws-wf-mic-btn { min-height: 48px; min-width: 48px; display: inline-flex; align-items: center; gap: var(--ws-2); flex: 0 0 auto; }
.ws-wf-mic-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ws-text-dim); display: inline-block; }
.ws-wf-mic-btn[data-state="listening"] .ws-wf-mic-dot { background: var(--ws-gold); animation: ws-wf-mic-pulse 1.1s ease-in-out infinite; }
.ws-wf-mic-btn[data-state="error"] .ws-wf-mic-dot { background: var(--ws-flare); animation: none; }
@keyframes ws-wf-mic-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.25; }
}

.ws-wf-notes-textarea {
  display: block; width: 100%; min-height: 50dvh; resize: vertical;
  font-family: var(--ws-mono); font-size: 16px; line-height: var(--ws-lh-tight);
  background: var(--ws-ink-900); color: var(--ws-text);
  border: var(--ws-border); border-radius: var(--ws-radius); padding: var(--ws-3);
}

.ws-wf-notes-chips {
  display: flex; gap: var(--ws-2); overflow-x: auto; padding: var(--ws-3) 0;
  -webkit-overflow-scrolling: touch;
}
.ws-wf-notes-chip {
  flex: 0 0 auto; min-height: 44px; display: inline-flex; flex-direction: column;
  align-items: flex-start; justify-content: center; gap: 2px;
  padding: var(--ws-2) var(--ws-3); border: var(--ws-border); border-radius: var(--ws-radius);
  background: var(--ws-ink-700); color: var(--ws-text); cursor: pointer;
}
.ws-wf-notes-chip:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }
.ws-wf-notes-chip[data-unlinked] { border-style: dashed; color: var(--ws-text-dim); }

.ws-wf-bygroup { margin-top: var(--ws-4); }

/* --- Notes: focused on a phone ----------------------------------------------
   wfSetNotesCompact() (workflow_ui.py) toggles this on body when
   #notes-textarea has focus. Courtside, with the keyboard up, the chrome
   above the textarea (role banner, utility strip, dictation row) was eating
   enough of the visible area to push the caret and the number strip under
   the keyboard — this frees that room back up rather than fighting the
   keyboard for it. Scoped to the same phone/landscape-phone query as the
   rest of this file's touch-target pass; a mouse-driven desktop never
   focuses this textarea under those conditions anyway. */
@media (max-width: 760px), (pointer: coarse) and (max-height: 500px) {
  body.ws-notes-focused .ws-wf-notes-textarea { min-height: 0; }

  /* The role banner — nothing a scout needs mid-line. */
  body.ws-notes-focused #role-note { display: none; }

  /* The utility strip's status line is the one thing worth keeping on
     screen (which data mode is live); the mode switch and the sign-out/
     account controls go, collapsing the strip back to one line. */
  body.ws-notes-focused #ws-utility-strip { padding-top: var(--ws-2); padding-bottom: var(--ws-2); min-height: 0; }
  body.ws-notes-focused .ws-utilitystrip-modes,
  body.ws-notes-focused .ws-utilitystrip-account { display: none; }

  /* The mic stays full-size and reachable (one-handed, without looking
     down) — only the preview line and the keyboard-mic hint under it fold
     away, so the row takes less height without losing the control itself. */
  body.ws-notes-focused .ws-wf-dictate-row { margin-bottom: var(--ws-1); }
  body.ws-notes-focused #notes-dictate-preview,
  body.ws-notes-focused #notes-dictate-hint { display: none; }
}

/* Quick capture (schema/024): the Unmatched captures rows on Home/Events and
   the Match to event panel on the notes screen. Rows wrap rather than scroll
   at phone width; every control keeps the 44px floor. */
.ws-wf-capture-row {
  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
  gap: var(--ws-3); min-height: 44px; padding: var(--ws-2) 0; border-bottom: var(--ws-border);
}
.ws-wf-capture-row:last-child { border-bottom: none; }
.ws-wf-match-new { margin-top: var(--ws-4); padding-top: var(--ws-3); border-top: var(--ws-border); }
.ws-wf-match-number { padding: var(--ws-2) 0; border-bottom: var(--ws-border); }
.ws-wf-match-number:last-child { border-bottom: none; }
.ws-wf-match-row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--ws-3); }
.ws-wf-match-row .ws-input { flex: 0 1 220px; min-width: 0; }
.ws-wf-match-lines { display: flex; flex-direction: column; gap: var(--ws-2); padding: var(--ws-3) 0 var(--ws-2) var(--ws-4); }
.ws-wf-match-lines .ws-checkbox { align-items: flex-start; min-height: 44px; }

/* --- Document blocks (wfDocBlock) -------------------------------------------
   One reusable component behind every generated report (strengths &
   weaknesses, team analysis, player impact, stat commentary, consistency,
   deep evaluation) on the evaluation and event screens. */
.ws-wf-bullets { margin: 0; padding-left: 1.1em; }
.ws-wf-bullets li { font-size: var(--ws-fs-body); margin: 2px 0; }
.ws-wf-toppick { padding: var(--ws-2) 0; border-bottom: 1px solid var(--ws-ink-700); }
.ws-wf-toppick:last-child { border-bottom: 0; }
.ws-wf-uncertain { outline: 2px solid var(--ws-flare); outline-offset: 1px; }

/* --- Statistics: the one-line stat line, monospace, everywhere it appears -- */
.ws-wf-statline-text {
  font-family: var(--ws-mono); font-size: var(--ws-fs-small); white-space: pre-wrap;
  margin: 0 0 var(--ws-2); color: var(--ws-text);
}

/* --- Roster status column: notes · stats · S&W · deep eval, at a glance -- */
.ws-wf-status { display: inline-flex; gap: var(--ws-2); }
.ws-wf-status-dot {
  font-family: var(--ws-display); font-size: var(--ws-fs-micro); font-weight: 700;
  color: var(--ws-text-ghost); letter-spacing: var(--ws-ls-micro);
}
.ws-wf-status-dot--draft { color: var(--ws-text-dim); }
.ws-wf-status-dot--final { color: var(--ws-gold); }

/* --- Print: the evaluation screen's branded report, hidden on screen ------
   Chrome (nav sidebar/drawer, buttons, edit textareas, stale callouts,
   history, raw notes) is stripped in the @media print block below instead of
   here, so the same markup serves both. The header band, player block and
   stats-line/highlights panel below are the same idea — print-only
   additions (WORKFLOW.md §7's branded redesign), filled by
   wfRenderEvalPrintPlayer/wfRenderEvalPrintStatline/wfLoadEvalPrintEventDate
   (WORKFLOW_SB_JS), never shown on screen. The footer has no on-screen
   counterpart to hide here at all — it is pure CSS (@page, further down),
   never an element — see #eval-print-footer-style's own comment. */
.ws-wf-print-masthead, .ws-wf-print-player, .ws-wf-print-statline,
.ws-wf-print-sw, .ws-wf-print-watermark {
  display: none;
}

/* --- Evaluation screen: sticky action bar, collapsed supporting detail ----
   The bar sticks to the top of the viewport (the topbar scrolls away) so
   Finalize/Print/Share never need a scroll to the bottom of a long page;
   inside the drawer, renderEvaluationShell sets an inline top equal to the
   drawer's own sticky header instead. */
.ws-wf-eval-actions {
  position: sticky; top: 0; z-index: 6;
  background: var(--ws-ink-800); border-bottom: var(--ws-border);
  padding: var(--ws-3) 0; margin: 0 0 var(--ws-5);
}
.ws-wf-eval-actions-row { display: flex; gap: var(--ws-2); flex-wrap: wrap; align-items: center; }
.ws-wf-eval-actions .ws-btn { padding: var(--ws-3) var(--ws-4); }
@media (max-width: 600px) {
  .ws-wf-eval-actions .ws-btn { padding: var(--ws-3); }
}
.ws-wf-eval-actions p { margin: var(--ws-2) 0 0; }
.ws-wf-eval-actions p:empty { display: none; }
.ws-wf-eval-more { position: relative; }
.ws-wf-eval-more > summary { list-style: none; }
.ws-wf-eval-more > summary::-webkit-details-marker { display: none; }
.ws-wf-eval-more-menu {
  position: absolute; right: 0; top: calc(100% + var(--ws-1)); z-index: 7;
  display: flex; flex-direction: column; gap: var(--ws-2); min-width: 160px;
  padding: var(--ws-2); background: var(--ws-ink-700); border: var(--ws-border);
}
.ws-wf-eval-disclosure { margin-top: var(--ws-4); border-top: var(--ws-border); }
.ws-wf-eval-disclosure > summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
/* The document block inside already sits under this summary's title. */
.ws-wf-eval-disclosure .ws-panel > .ws-section-head { display: none; }
.ws-wf-doc-about { margin: 0 0 var(--ws-2); }
.ws-wf-doc-about > summary { cursor: pointer; min-height: 44px; display: inline-flex; align-items: center; }
/* The opposite of .ws-wf-doc-meta below: never shown on screen (the on-
   screen "Draft"/"Final" badge inside the document body already covers it),
   only in a printed/PDF copy, per document — see wfDocBlockHtml(). */
.ws-wf-doc-print-status { display: none; }

/* --- Utility strip: Live / Empty / Sample + account (standalone build only)
   One shared bar directly under the topbar, identical on every route once
   signed in as a scout — scout screens AND player-preview, since the
   preview is still the scout's own session (renderAuthPanel() owns showing
   and hiding it, via body[data-locked] below). Sample mode's gold
   border-bottom is the one thing on screen that cannot be mistaken for a
   live app; Empty gets a dim one; Live a plain hairline. The print
   watermark below covers the case where a screen gets printed while
   Sample is on. */
.ws-utilitystrip {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--ws-2) var(--ws-4);
  min-height: 48px;
  padding: var(--ws-2) var(--ws-6);
  background: var(--ws-ink-900);
  /* Border WIDTH never changes with mode, only colour — the strip's own box
     (and everything below it) must land in the exact same place on every
     route regardless of mode, so only the colour carries the signal. */
  border-bottom: 1px solid var(--ws-ink-500);
}
.ws-utilitystrip[data-mode="empty"] { border-bottom-color: var(--ws-text-dim); }
.ws-utilitystrip[data-mode="sample"] { border-bottom-color: var(--ws-gold); }
.ws-utilitystrip-modes { flex: 0 0 auto; }
/* The one thing that changes with mode — centred between the control and
   the account block on desktop, left-aligned on its own row on phones (see
   the max-width:600px block below). Ellipsis, not wrap: the Sample copy is
   the longest of the three and must never force a third row. */
.ws-utilitystrip-status {
  /* flex-basis:0, not auto — auto would size this item (for the purposes of
     the OUTER container's wrap decision) off its un-shrunk content, and the
     Sample copy is long enough to bump the account block onto a third row
     before the ellipsis below ever gets a chance to apply. */
  flex: 1 1 0%; min-width: 0;
  display: flex; align-items: center; gap: var(--ws-3);
  justify-content: center; text-align: center;
}
/* min-width:0 is load-bearing: a flex item's default min-width is its
   content's intrinsic (nowrap) size, which for the Sample copy is wide
   enough to overflow this row and wrap the account block onto a third row
   — exactly the "2 rows max on phone" rule this element exists to keep. */
.ws-utilitystrip-status-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-utilitystrip-account { flex: 0 0 auto; display: flex; align-items: center; gap: var(--ws-4); }

@media (max-width: 600px) {
  .ws-utilitystrip { padding: var(--ws-3) var(--ws-4); }
  /* Control row first, full width; status + name/sign-out share the second
     row (the brief's phone layout) — forcing the modes control to 100%
     width is what pushes the other two onto their own line. */
  .ws-utilitystrip-modes { flex: 1 1 100%; }
  .ws-utilitystrip-status { justify-content: flex-start; text-align: left; }
}

.ws-segmented {
  display: inline-flex;
  border: var(--ws-border);
  border-radius: 4px;
  overflow: hidden;
}
.ws-segmented button {
  min-height: 44px; min-width: 44px;
  padding: 0 var(--ws-4);
  background: transparent;
  border: none;
  border-left: var(--ws-border);
  color: var(--ws-text-dim);
  font-family: var(--ws-display);
  font-size: var(--ws-fs-micro);
  letter-spacing: var(--ws-ls-micro);
  text-transform: uppercase;
  cursor: pointer;
}
.ws-segmented button:first-child { border-left: none; }
.ws-segmented button[aria-pressed="true"] { background: var(--ws-ink-700); color: var(--ws-gold); }
.ws-segmented button:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: -2px; }
/* The strip's copy of the control: same 44px targets everywhere (the
   mobile audit's hard assertion allows no exception for a compact context) —
   only the horizontal padding shrinks so it still fits the slim bar. */
.ws-segmented--sm button { padding: 0 var(--ws-2); }

@media print {
  #ws-sidebar, #ws-sidebar-scrim, #nav-toggle, .ws-topbar, #sb-auth-wrap, .ws-roleswitch, #ws-utility-strip,
  .sample-banner, #role-note,
  .ws-wf-print-hide, .ws-wf-doc-toolbar, .ws-wf-doc-meta, .ws-wf-doc-callout,
  .ws-wf-doc-history, .ws-wf-doc-edit {
    display: none !important;
  }
  /* The sidebar's reserved column (body.ws padding-left, ≥900px) would
     otherwise leave a blank strip down a printed page's left edge now that
     the sidebar above is hidden. */
  body.ws { padding-left: 0 !important; }
  /* S7: the printed evaluation is a branded report, fixed order — header
     band, player block (both at their default order, ahead of everything
     below), then strengths/weaknesses/projection (#eval-print-sw), the deep
     evaluation, then the stats-line/highlights panel. Everything else
     inside #view-evaluation (the 13-category S&W checklist, raw notes, the
     WS360 widget, every screen-only doc toolbar) is already display:none
     via ws-wf-print-hide above, as is any of these three sections with
     nothing in it (renderEvaluationShell toggles that). */
  #view-evaluation { display: flex; flex-direction: column; }
  #eval-sw-section { order: 1; }
  #eval-deepeval-section { order: 2; }
  #eval-stats-section { order: 3; }
  .ws-wf-print-sw { display: block !important; }
  /* Pure white, not --ws-bone's warm off-white — paper reads cleaner flat
     white than the product's own ground tint (explicit call, overrides
     BRAND.md §2's usual "never substitute #FFF", scoped to this one
     stylesheet context). */
  body, .ws-shell { background: #fff !important; color: var(--ws-ink) !important; }
  .ws-panel {
    background: #fff !important; border: 1px solid var(--ws-ink);
    break-inside: avoid; page-break-inside: avoid;
  }
  /* The deep evaluation reads as an editorial document, not a boxed card —
     no border, no inset padding, no second "Deep evaluation" title (the
     section heading above it already carries that; wfDocBlockHtml() gives
     every document panel its own .ws-section-head, which is right for the
     on-screen card stack but redundant once the box around it is gone).
     break-inside:auto (not the generic rule's avoid, above) for the same
     reason as before: a page with only the header band and player block
     left on it would otherwise push this whole five-section panel onto a
     mostly-blank next page rather than starting it where it fits. Each
     section stays protected individually by .ws-field below. */
  #eval-deepeval-section .ws-panel {
    background: transparent !important; border: none; padding: 0;
    break-inside: auto; page-break-inside: auto;
  }
  #eval-deepeval-section .ws-panel > .ws-section-head { display: none !important; }
  a { color: var(--ws-ink) !important; text-decoration: underline; }
  .ws-label, .ws-label--dim, .ws-field-value, .ws-micro { color: var(--ws-ink) !important; }
  /* --ws-text-mut is near-white at low opacity, built for the dark ground
     — an unlinked/sparse entry's "Not generated yet." (deep evaluation,
     stats) would otherwise print as a near-invisible smudge instead of a
     muted but legible line. */
  .ws-empty { color: rgba(13, 13, 13, 0.5) !important; }
  /* No heading left stranded at the foot of a page — the deep evaluation's
     five sections are each a .ws-field wrapping a .ws-label--dim heading
     (wfDocReadField, WORKFLOW_SB_JS), same shape as every other document.
     orphans/widows: a heading's first paragraph never leaves just one or
     two lines stranded at a page break either. */
  .ws-label, .ws-label--dim { break-after: avoid; page-break-after: avoid; }
  .ws-field { break-inside: avoid; page-break-inside: avoid; }
  .ws-field-value { orphans: 3; widows: 3; }
  /* Lighter than .ws-field's own border-bottom (--ws-ink-700, built for the
     dark ground): a near-black rule reads heavier on pure white than it did
     on ink. Still a rule between every section, just a quieter one — the
     editorial-document feel the box's removal is going for. */
  #eval-deepeval-section .ws-field { border-bottom-color: rgba(13, 13, 13, 0.15); }
  /* Quiet metadata, not a second headline — the document's own "Deep
     evaluation" section heading (and the player block above it) already
     carry the visual weight on this page. */
  .ws-wf-doc-print-status {
    display: block !important; font-size: var(--ws-fs-micro);
    letter-spacing: var(--ws-ls-micro); color: rgba(13, 13, 13, 0.55) !important;
    margin: 0 0 var(--ws-3);
  }

  /* --- Header band: logo, "Scouting Report", event name + date, gold rule.
     Ink/gold like the rest of the brand (BRAND.md §2's "gold only ever sits
     on ink") rather than black-on-white like the body text below it — the
     one deliberate spot of colour on an otherwise print-safe page, so it
     needs print-color-adjust itself. */
  .ws-wf-print-masthead {
    display: flex !important; align-items: center; gap: var(--ws-4);
    padding: var(--ws-3) var(--ws-4); margin: 0 0 var(--ws-5);
    background: var(--ws-ink) !important; border-bottom: 3px solid var(--ws-gold);
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
    break-inside: avoid; page-break-inside: avoid;
  }
  .ws-wf-print-logo { height: 26px; width: auto; display: block; }
  .ws-wf-print-masthead-text { display: flex; flex-direction: column; gap: 2px; }
  /* Specificity (class + tag) beats the .ws-micro/.ws-label ink sweep above
     — same property, same !important, so the extra tag selector is what
     decides it — keeping the band's own text bone-on-ink instead of forced
     to ink like every other printed label on the page. */
  .ws-wf-print-masthead p {
    margin: 0; color: var(--ws-bone) !important; font-size: var(--ws-fs-small);
  }
  .ws-wf-print-kicker {
    font-family: var(--ws-display); font-weight: 700; text-transform: uppercase;
    letter-spacing: var(--ws-ls-micro); color: var(--ws-gold) !important;
  }

  /* --- Player block: name, jersey/team/position, height/wingspan/weight/
     age. "—" per field is already baked in by wfRenderEvalPrintPlayer
     (WORKFLOW_SB_JS) rather than CSS, so there is no empty-field case here
     to hide. */
  .ws-wf-print-player {
    display: block !important; margin: 0 0 var(--ws-5);
    break-inside: avoid; page-break-inside: avoid;
  }
  .ws-wf-print-player-name {
    font-family: var(--ws-display); font-weight: 700; text-transform: uppercase;
    font-size: var(--ws-fs-display-s); line-height: var(--ws-lh-tight); margin: 0 0 var(--ws-2);
  }
  .ws-wf-print-player-row, .ws-wf-print-player-vitals {
    display: flex; gap: var(--ws-4); flex-wrap: wrap; margin: 2px 0;
    font-size: var(--ws-fs-small); color: var(--ws-ink);
  }

  /* --- Stats Line, tinted, then Key Highlights below it (S7) — one group so
     a page break never falls between the two. */
  .ws-wf-print-statline {
    display: block !important; break-inside: avoid; page-break-inside: avoid;
  }
  .ws-wf-print-tinted {
    background: var(--ws-ink-700) !important; color: var(--ws-bone) !important;
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
    border: 1px solid var(--ws-ink); padding: var(--ws-3) var(--ws-4); margin: 0 0 var(--ws-3);
  }
  .ws-wf-print-tinted .ws-wf-statline-text { color: var(--ws-bone) !important; margin: 0; }

  /* --- Watermark: very faint brand mark, centred. Optional per S7, kept
     subtle and legible. Negative z-index puts a fixed-position element
     behind the page's normal in-flow content (CSS stacking order), no
     positioned ancestor required — the content above it never needs its own
     z-index for this to hold. */
  .ws-wf-print-watermark {
    display: block !important; position: fixed; top: 50%; left: 50%;
    width: 50%; height: auto; transform: translate(-50%, -50%);
    opacity: 0.05; z-index: -1;
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
  }

  /* WS360 profile block (COMBINE.md §4): its own text classes sit outside
     the .ws-label/.ws-field-value/.ws-micro sweep above. Dead code now that
     #eval-ws360-section is ws-wf-print-hide (S7 dropped the widget from the
     printed report entirely — see the view markup's own comment) but left
     in place rather than deleted, since ws360Block() is shared with other,
     still-printable surfaces (the record page has no print stylesheet of
     its own yet, but nothing stops one reusing this block later). */
  .ws-360-headline, .ws-360-part-name, .ws-360-part-band, .ws-360-part-coverage,
  .ws-360-part-source, .ws-360-part-notes, .ws-360-metric-row p,
  .ws-360-derived p, .ws-360-missing, .ws-360-comment, .ws-360-unverified,
  .ws-conf-text {
    color: var(--ws-ink) !important;
  }
  /* The inline mark reads onlight on paper regardless of the data-variant
     it was authored with — gold text fails on bone (BRAND.md's hard color
     rule), so the "360" drops to ink along with everything else here. */
  .ws-360-lockup, .ws-360-360 { color: var(--ws-ink) !important; }
}

/* Both paper sizes are fine — size:auto defers to whatever the print
   dialog/PDF target already has set rather than forcing one. The bottom
   margin leaves room for the footer's own margin-box content (below and in
   #eval-print-footer-style, WORKFLOW_SB_JS). Margin boxes only render in
   engines that implement CSS Paged Media — harmless where unsupported,
   "where the browser supports them" per S7; tested directly against this
   build's Chromium/Playwright target, where they do, and reliably survive
   DOM changes elsewhere on the page (Finalize, Regenerate, …) in a way a
   plain position:fixed HTML footer here did not (see that comment's
   history in git blame if this ever needs revisiting). */
@page {
  size: auto;
  margin: 15mm 14mm 20mm 14mm;
}
@page {
  @bottom-center {
    content: counter(page) " / " counter(pages);
    font-family: var(--ws-mono); font-size: 9px; color: var(--ws-ink);
  }
}

/* --- Record → Scouting work: two kinds of report (schema/025) -------------
   A tag above each heading says which kind you are looking at — the player
   report (all events, gold on ink) or an event report (one per event,
   muted) — so on a phone, where the two stack, they can never be confused.
   The event reports are a list of rows, not a table: nothing to scroll
   sideways at 390px. */
.ws-wf-kind-tag {
  display: inline-block; margin: 0 0 var(--ws-2); padding: 2px var(--ws-2);
  font-family: var(--ws-display); font-size: var(--ws-fs-micro); font-weight: 700;
  letter-spacing: var(--ws-ls-micro); text-transform: uppercase; line-height: 1.4;
  color: var(--ws-text-dim); border: 1px solid var(--ws-ink-500); border-radius: var(--ws-radius);
}
.ws-wf-kind-tag--player { color: var(--ws-gold); border-color: var(--ws-gold); }
.ws-wf-evlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ws-3); }
.ws-wf-evrow {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--ws-3) var(--ws-4);
  padding: var(--ws-4); border: var(--ws-border); border-radius: var(--ws-radius);
}
.ws-wf-evrow-main { flex: 1 1 260px; min-width: 0; }
.ws-wf-evrow .ws-wf-kind-tag { margin-bottom: var(--ws-1); }
/* The player report's print-only block (header band + player card) — never
   on screen; in print only while body.ws-print-player-report is set. */
.ws-wf-pr-print { display: none; }

@media print {
  /* Printing the player report (wfPreparePlayerReportPrint, workflow_ui.py):
     the same branded layout as the event report — header band, player card,
     footer — with only #record-player-report on the page. The event-report
     list, the player shell's header and tabs all drop out. */
  body.ws-print-player-report #view-player > :not(#view-player-work),
  body.ws-print-player-report #view-player-work > :not(#record-player-report),
  body.ws-print-player-report .ws-footer {
    display: none !important;
  }
  body.ws-print-player-report .ws-wf-pr-print { display: block !important; }
  body:not(.ws-print-player-report) .ws-wf-pr-print { display: none !important; }
  /* An editorial document, not a boxed card — same treatment as the event
     report's deep evaluation (#eval-deepeval-section above). */
  #record-player-report .ws-panel {
    background: transparent !important; border: none; padding: 0;
    break-inside: auto; page-break-inside: auto;
  }
  #record-player-report .ws-panel > .ws-section-head { display: none !important; }
  #record-player-report .ws-field { border-bottom-color: rgba(13, 13, 13, 0.15); }
  /* Its sections are lists that can run long: one may continue onto the
     next page between bullets (never inside one, never right after its
     heading — .ws-label's break-after above) instead of leaving a blank
     third of a page behind it. */
  #record-player-report .ws-field { break-inside: auto; page-break-inside: auto; }
  #record-player-report .ws-wf-bullets li { break-inside: avoid; page-break-inside: avoid; }
}

/* --- Auth gate: locked until sign-in (standalone build only) --------------
   [data-locked] is set on <body> by default in the raw standalone HTML (see
   build.py) and toggled from there on by applyAuthGate() (gen_prototype.py,
   SB JS block) — "you shouldn't be able to see anything until you sign in",
   for every route and both preview roles. Nothing here ever matches in the
   artifact build: its <body> carries neither the attribute nor the JS that
   would ever set it, and #sb-auth-wrap does not exist in its markup at all
   (SB HTML markers strip it) — see gen_prototype.py's renderAuthPanel(). */
body[data-locked] #ws-sidebar,
body[data-locked] #ws-sidebar-scrim,
body[data-locked] #nav-toggle,
body[data-locked] .ws-viewas,
body[data-locked] #ws-utility-strip,
body[data-locked] #role-note,
body[data-locked] .view,
body[data-locked] .ws-footer {
  display: none !important;
}
/* Centred, phone-first — the one thing on screen, not the roster/topbar
   layout it normally sits inside. */
body[data-locked] #sb-auth-wrap {
  display: flex !important;
  align-items: center; justify-content: center;
  min-height: calc(100vh - var(--ws-topbar-h, 0px));
  padding: var(--ws-4) !important;
}
body[data-locked] #sb-auth-panel {
  width: 100%; max-width: 420px;
}

/* One CTA per screen (§ owner note): every state renderAuthPanel() can put
   on the locked screen — signed_out's two modes, checking, no_access,
   recovery — shares this one stacked, full-width, centred layout so the
   panel never resizes or reflows switching between them. */
.ws-auth-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--ws-4);
  text-align: center;
}
.ws-auth-logo { height: 32px; width: auto; display: block; }
.ws-auth-stack .ws-input,
.ws-auth-stack .ws-btn {
  width: 100%;
  min-height: 44px;
  font-size: 16px; /* never below 16px — iOS zooms the page on focus under that */
}
.ws-auth-stack #sb-signin-msg { margin: 0; }
.ws-auth-links-row {
  display: flex;
  justify-content: center;
  gap: var(--ws-5);
  flex-wrap: wrap;
  width: 100%;
}
/* A plain text link, not a second button box next to the one primary CTA
   above it — "Have a password? Sign in with it", "Forgot password?", "Use a
   sign-in link instead". Muted until hovered/focused, 44px tap height via
   padding rather than a border or fill. */
.ws-link-quiet {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: var(--ws-2) 0;
  font-family: var(--ws-body);
  font-size: var(--ws-fs-small);
  color: var(--ws-text-mut);
  text-decoration: none;
}
.ws-link-quiet:hover { color: var(--ws-text); text-decoration: underline; }
.ws-link-quiet:focus-visible { outline: 2px solid var(--ws-gold); outline-offset: 2px; }

/* --- Locked-screen ground: the pebble texture, faint --------------------
   Same crop as .ws-textured above, layered as a background rather than an
   ::before so a radial vignette can dampen it further: a semi-opaque ink
   veil (rgba below matches --ws-ink-800's own #0D0D0D) painted over the
   image, near-opaque at the edges and at its most transparent — still only
   ~10% show-through, never a literal 0% cutout — at the centre. That caps
   the visible strength (target ~8-10% show-through) without a background
   layer's non-existent opacity. background-attachment: fixed is desktop
   only below — iOS ignores it outright, and on phones that do honour it,
   fixed can repaint/jank while scrolling a full-height locked screen; plain
   scroll still covers via background-size: cover either way. */
body[data-locked] {
  /* No sidebar on screen while locked — its reserved column (body.ws
     padding-left, ≥900px) would otherwise leave the centred auth panel
     below off-centre. */
  padding-left: 0 !important;
  background-color: var(--ws-ink-800);
  background-image:
    radial-gradient(circle at 50% 40%, rgba(13, 13, 13, 0.90) 0%, var(--ws-ink-800) 75%),
    url("/assets/texture-pebble.png");
  background-size: cover, cover;
  background-position: 50% 40%, 50% 40%;
  background-repeat: no-repeat, no-repeat;
}
@media (min-width: 641px) {
  body[data-locked] { background-attachment: fixed; }
}

/* ============================================================================
   WS360 profile block + Combines/Combine — new in this pass. ws360Block() /
   wsBadgeSvg() live in workflow_ui.py (WORKFLOW_JS/WORKFLOW_SB_JS); the
   combines/combine screens are markup + WORKFLOW_SB_JS. Prefixed .ws-360-
   for the profile block, .ws-wf- (joining the WORKFLOW section above) for
   everything else — built only from tokens.css, same rule as that section.
   See COMBINE.md §4.
   ============================================================================ */

.ws-360-block { display: block; }

.ws-360-headline-row {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--ws-3); flex-wrap: wrap; margin-bottom: var(--ws-4);
}
.ws-360-headline {
  font-family: var(--ws-display); font-weight: 700; font-size: var(--ws-fs-body);
  text-transform: uppercase; letter-spacing: var(--ws-ls-micro); color: var(--ws-text);
  margin: 0;
}
.ws-360-block--compact .ws-360-headline { font-size: var(--ws-fs-small); }
.ws-360-evidence { display: flex; align-items: center; gap: var(--ws-2); flex: 0 0 auto; }
/* confidence.dots (COMBINE.md §3) is an independent count from
   confidence.level — the fixed early=1/developing=3/stronger=5 cascade
   .ws-conf[data-level] alone gives (see the Confidence track section,
   above) cannot always be trusted, so ws360Block sets this per-dot
   attribute directly; it wins whenever it is present. */
.ws-conf i[data-on] { background: var(--ws-gold); border-color: var(--ws-gold); }

.ws-360-top { display: flex; gap: var(--ws-5); align-items: flex-start; flex-wrap: wrap; }
.ws-360-parts { flex: 1 1 260px; min-width: 0; display: flex; flex-direction: column; gap: var(--ws-2); }

.ws-360-part { border-bottom: 1px solid var(--ws-ink-700); padding-bottom: var(--ws-2); }
.ws-360-part:last-child { border-bottom: 0; }
.ws-360-part-toggle {
  display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--ws-3);
  align-items: baseline; width: 100%; min-height: 44px; text-align: left;
  background: none; border: 0; color: var(--ws-text); cursor: pointer; padding: var(--ws-2) 0;
  font-family: var(--ws-body); font-size: var(--ws-fs-small);
}
.ws-360-part-toggle:disabled { cursor: default; }
.ws-360-part-toggle--static { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: var(--ws-3); align-items: baseline; padding: var(--ws-2) 0; }
.ws-360-part-name {
  font-family: var(--ws-display); font-weight: 700; font-size: var(--ws-fs-micro);
  text-transform: uppercase; letter-spacing: var(--ws-ls-micro); color: var(--ws-text-dim);
}
.ws-360-part-band { color: var(--ws-text); }
.ws-360-part-coverage, .ws-360-part-source { color: var(--ws-text-mut); font-size: var(--ws-fs-micro); }
/* Sentence case, deliberately not .ws-micro's uppercase treatment — this is
   a written sentence from the founder's own words (COMBINE.md §3's `notes`),
   not a short meta tag. */
.ws-360-part-notes {
  font-family: var(--ws-body); font-size: var(--ws-fs-micro); line-height: 1.4;
  margin: 0 0 var(--ws-2); color: var(--ws-text-mut);
}
.ws-360-part-detail { padding: var(--ws-2) 0 var(--ws-3); }
.ws-360-metric-row { padding: var(--ws-2) 0; border-top: 1px solid var(--ws-ink-800); }
.ws-360-metric-row:first-child { border-top: 0; }

.ws-360-derived, .ws-360-unverified, .ws-360-next { margin-top: var(--ws-4); padding-top: var(--ws-3); border-top: var(--ws-border); }
.ws-360-missing { margin-top: var(--ws-3); }
.ws-360-comment { margin-top: var(--ws-3); font-style: italic; }

@media (max-width: 640px) {
  .ws-360-part-toggle, .ws-360-part-toggle--static { grid-template-columns: 1fr; gap: 2px; }
}

/* Provisional (confidence.level === "early"): nothing in the badge is gold
   and the arc goes dashed — the same rule the older .ws-score[data-provisional]
   pattern applies (WS360 rating section, above), restated directly on
   .ws-badge since ws360Block never wraps its badge in a .ws-score panel. */
.ws-badge[data-provisional] .ws-badge-arc { stroke: var(--ws-text-dim); stroke-dasharray: 5 4; }
.ws-badge[data-provisional] .ws-badge-dot { fill: var(--ws-text-dim); }
.ws-badge[data-provisional] .ws-badge-dot[data-state="absent"] { fill: var(--ws-ink-700); }
/* Combines/combine's three tables (combines list, entries, reference bands)
   already carry .ws-wf-table (see the phone-width table rule earlier in the
   WORKFLOW section) — no separate rule needed here. */

/* ============================================================================
   Inline WS360 mark — brand/BRAND.md §10b "Inline mark". The identity
   lockup, not the coverage chart above: same 72°-apart, half-step-offset
   ring as .ws-badge (R=46 on a 0 0 120 120 viewBox — see WS360_MARK_SYMBOL,
   gen_marketing.py / gen_prototype.py), drawn once as <symbol id="ws360-
   mark"> and referenced everywhere with <use>. No arc, no number, no
   per-dot state — solid dots, hairline ring, always. Never interactive, so
   the 44px target-size rule does not apply.
   ============================================================================ */

.ws-360-lockup {
  display: inline-flex; align-items: baseline; gap: 0.3em;
  vertical-align: baseline; white-space: nowrap; color: inherit;
}
.ws-360-mark {
  align-self: center; color: currentColor; vertical-align: -0.1em;
}
.ws-360-mark-ring { fill: none; stroke: currentColor; stroke-width: 1.5; opacity: 0.6; }
.ws-360-mark-dot  { fill: currentColor; stroke: none; }

.ws-360-word {
  font-family: var(--ws-display); font-weight: 700; text-transform: uppercase;
  letter-spacing: -0.01em; line-height: 1;
}
/* The only gold in a lockup. Never on a light ground — see [data-variant]
   below and the @media print override, which both drop it to currentColor. */
.ws-360-360 { color: var(--ws-gold); }
.ws-360-lockup[data-variant="onlight"] .ws-360-360 { color: inherit; }

/* sm: cap-height of the surrounding text, wordmark only — no font-size of
   its own, and no ring+dots (they read as specks at this size; the
   ws360_lockup()/ws360Lockup() helpers omit the <svg> entirely at sm).
   md: section/card heading, with the ring. lg: hero/page heading, with the
   ring. */
.ws-360-lockup--md { font-size: var(--ws-fs-display-m); }
.ws-360-lockup--md .ws-360-mark { width: 0.78em; height: 0.78em; }
.ws-360-lockup--lg { font-size: var(--ws-fs-display-l); }
.ws-360-lockup--lg .ws-360-mark { width: 0.72em; height: 0.72em; }


/* --- prototype --- */
.sample-banner { background: var(--ws-ink-900); border-bottom: var(--ws-border); padding: var(--ws-2) var(--ws-6); text-align: center; }
  /* "no sample data" stops being true the moment real demo data is loaded
     (FORCED_MODE = "demo", below) — body[data-mode] is set by
     renderUtilityStrip() the same way it already drives the print rule for
     "DEMO DATA — FICTIONAL" (components.css), so this banner disappears
     under the exact condition that makes its own wording wrong. */
  body[data-mode="demo"] .sample-banner { display: none; }
  .view[hidden] { display: none !important; }
  .record-head { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(0,1fr); gap: var(--ws-7); align-items: start; }
  .scoutview-head { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: var(--ws-7); align-items: start; }
  .signal-col h3 { margin: 0 0 var(--ws-2); }
  .strength-cluster { display: flex; align-items: center; gap: var(--ws-5); }
  @media (max-width: 900px) { .record-head, .scoutview-head { grid-template-columns: minmax(0,1fr); gap: var(--ws-5); } }

/* --- design system gallery --- */
.spec { margin-bottom: var(--ws-9); }
  .swatch { border: var(--ws-border); border-radius: var(--ws-radius); overflow: hidden; }
  .swatch-chip { height: 88px; }
  .swatch-meta { padding: var(--ws-3); background: var(--ws-ink-700); }
  .note { border-left: 2px solid var(--ws-gold); padding-left: var(--ws-4); color: var(--ws-text-dim); max-width: 66ch; }
  .bad { border-left-color: var(--ws-flare); }
  .demo { background: var(--ws-ink-700); border: var(--ws-border); border-radius: var(--ws-radius); padding: var(--ws-5); }