/* ==========================================================================
   The Next Coin Club — states.css
   The transient UI layer. Loads AFTER app.css and components.css.

   WHAT THIS FILE IS FOR
   ---------------------
   Everything in app.css and components.css describes the interface at rest:
   surfaces, type, rows, tiles. Nothing in the platform described what the
   interface does when it is *working, failing, asking, or interrupting*.
   Each page invented its own answer — signin.html hand-rolled `.alertbox`
   and `.field.bad`, dashboard.html mutated button labels in place
   (`b.textContent = 'Calendar file downloaded'`), settings.html turned a
   destructive action into a two-click text swap. Those are four different
   grammars for the same sentence.

   This file is the single grammar.

   NAMESPACE
   ---------
   Every new component is prefixed `ncc-`. This is deliberate: the pages
   already own a large flat namespace (.banner, .card, .note, .empty, .field,
   .step, .grid …) and this file must not collide with any of it. The one
   exception is §4, which *extends* the existing `.field` validation contract
   that signin/signup/reset already use, adding only states those pages have
   no rule for. Nothing existing is redefined.

   LOAD ORDER  (see §14 for the exact tags)
   ----------
       app.css  ->  components.css  ->  effects.css  ->  states.css
   ...and states.css must come AFTER each page's inline <style> block, or
   page-local rules of equal specificity will win. See §14.

   CONSTRAINTS HONOURED
   --------------------
   - Monochrome. Severity is carried by contrast weight, an accent rule, and
     an icon — never by hue. The one concession: if a page has already
     defined --err (signin/signup/reset/verify do), this file will use it.
     Where it is absent everything degrades to the neutral ramp and still
     reads correctly.
   - Existing tokens only. No new colour values are introduced.
   - Both themes, via the same tokens that already flip.
   - prefers-reduced-motion respected on every animation (§12).
   - No JS shipped. The minimal driver is documented in §14.

   CONTENTS
   --------
   §1   State tokens (derived, not new)
   §2   Toasts
   §3   Inline alerts / banners
   §4   Form validation states
   §5   Modal / dialog / bottom sheet
   §6   Empty states
   §7   Loading states
   §8   Error states
   §9   Offline / connection banner
   §10  Tooltip
   §11  Confirmation / destructive pattern
   §12  Focus-visible refinement + reduced motion
   §13  Utilities
   §14  JS companion (documented, not shipped)
   ========================================================================== */


/* ==========================================================================
   §1  STATE TOKENS
   --------------------------------------------------------------------------
   Derived entirely from app.css. Each token falls back through a chain so
   the file is correct whether or not a page has defined the auth-page error
   trio (--err / --err-bg / --err-bd).

   The severity ramp is a CONTRAST ramp, not a colour ramp:

     info      quiet   — --dim text,  --hair border,   no accent rule
     success   quiet   — --ok  accent (the one semantic colour app.css owns)
     warning   medium  — --text text, --hair2 border,  2px accent rule
     error     loud    — --text text, --err-bd border, 3px accent rule

   In a monochrome system this is the honest way to rank urgency: the eye
   reads edge weight and local contrast long before it reads hue, and it
   keeps working for the ~8% of men with a colour vision deficiency, in
   forced-colors mode, and on a black-and-white print.
   ========================================================================== */

:root{
  /* Accent rule widths — the primary severity signal. */
  --st-rule-info:  0px;
  --st-rule-ok:    2px;
  --st-rule-warn:  2px;
  --st-rule-err:   3px;

  /* Geometry, borrowed from the existing radius scale. */
  --st-radius:     14px;                  /* matches input + .alertbox */
  --st-gap:        11px;                  /* matches .alertbox gap     */
  --st-pad-y:      13px;
  --st-pad-x:      15px;

  /* Stacking. Deliberately layered ABOVE the member tab bar (z-index 50),
     the sticky nav (60), the sticky bar (70) and .rprog (80), but BELOW the
     command palette (100), which is a full-attention surface of its own. */
  --st-z-tooltip:  85;
  --st-z-toast:    90;
  --st-z-modal:    95;

  /* Toast container inset. Uses env() with a 0px fallback so non-notched
     devices and older browsers are unaffected. */
  --st-safe-b:     env(safe-area-inset-bottom, 0px);
  --st-safe-t:     env(safe-area-inset-top, 0px);
  --st-safe-l:     env(safe-area-inset-left, 0px);
  --st-safe-r:     env(safe-area-inset-right, 0px);
}

/* Severity surfaces. Fallback chain: page-defined --err, else neutral.
   `var(--err, X)` resolves to X when --err is not defined anywhere in the
   cascade, which is exactly the graceful-degradation path we want. */
:root{
  --st-err:        var(--err, var(--text));
  --st-err-bg:     var(--err-bg, var(--sunk));
  --st-err-bd:     var(--err-bd, var(--hair2));

  --st-warn:       var(--text);
  --st-warn-bg:    var(--sunk);
  --st-warn-bd:    var(--hair2);

  --st-info:       var(--dim);
  --st-info-bg:    var(--sunk);
  --st-info-bd:    var(--hair);

  --st-ok:         var(--ok);
  --st-ok-bg:      var(--ok-bg);
  --st-ok-bd:      var(--ok-bd);
}


/* ==========================================================================
   §2  TOASTS
   --------------------------------------------------------------------------
   A toast is for confirmation of something the user just did. It must never
   be the only place a piece of information appears, and it must never cover
   a control the user might reach for next — which on this platform means it
   must clear the member tab bar.

   POSITIONING
   -----------
   Desktop: bottom-right, above the fold of the viewport.
   Mobile:  bottom-centre, lifted by the tab bar height (62px) + the home
            indicator inset, so it floats *above* the tabs rather than
            landing on them. On pages without .tabs the lift collapses to
            the safe-area inset alone — see the body:not(.member) rule.
   ========================================================================== */

.ncc-toasts{
  position:fixed;
  z-index:var(--st-z-toast);
  display:flex;
  flex-direction:column;
  gap:10px;
  pointer-events:none;              /* the container never eats clicks */
  /* Desktop default: bottom-right, clear of the scrollbar. */
  right:calc(20px + var(--st-safe-r));
  bottom:calc(20px + var(--st-safe-b));
  left:auto;
  width:min(380px,calc(100vw - 40px));
  align-items:flex-end;
}
/* Newest toast nearest the thumb: the stack grows upward from the bottom. */
.ncc-toasts.ncc-toasts--top{
  top:calc(20px + var(--st-safe-t));
  bottom:auto;
  flex-direction:column-reverse;
}

/* Mobile: centre it and clear the tab bar.
   62px is the measured height of .tabs (8px top pad + 17px icon + 3px gap +
   ~10px label + 13px bottom pad). The extra 12px is breathing room. */
@media(max-width:899px){
  .ncc-toasts{
    left:calc(12px + var(--st-safe-l));
    right:calc(12px + var(--st-safe-r));
    width:auto;
    align-items:stretch;
    bottom:calc(62px + 12px + var(--st-safe-b));
  }
  /* Pages that are not the member shell have no tab bar to clear. */
  body:not(.member) .ncc-toasts{
    bottom:calc(16px + var(--st-safe-b));
  }
  /* …but the marketing pages DO show .stickybar below 640px. */
  @media(max-width:640px){
    body:not(.member) .ncc-toasts{
      bottom:calc(64px + 12px + var(--st-safe-b));
    }
  }
}

.ncc-toast{
  pointer-events:auto;              /* …but each toast does */
  position:relative;
  display:flex;
  align-items:flex-start;
  gap:var(--st-gap);
  background:var(--raised);
  border:1px solid var(--hair2);
  border-radius:var(--st-radius);
  box-shadow:var(--sh-lg);
  padding:var(--st-pad-y) var(--st-pad-x);
  padding-left:calc(var(--st-pad-x) + var(--st-rule,0px));
  overflow:hidden;
  isolation:isolate;
  animation:ncc-toast-in .34s var(--spring) both;
}
/* The accent rule — the severity signal. Drawn as a pseudo-element so the
   toast's own border-radius clips it cleanly at the corners. */
.ncc-toast::before{
  content:'';
  position:absolute;
  left:0; top:0; bottom:0;
  width:var(--st-rule,0px);
  background:var(--st-accent,transparent);
  z-index:1;
}
.ncc-toast > *{position:relative;z-index:2}

.ncc-toast .ncc-toast-ic{
  width:18px;height:18px;flex-shrink:0;margin-top:1px;
  stroke:var(--st-accent,var(--dim));
  fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;
}
.ncc-toast .ncc-toast-body{flex:1;min-width:0}
.ncc-toast .ncc-toast-title{
  font-size:13.5px;font-weight:600;color:var(--text);line-height:1.45;
  letter-spacing:-.006em;
}
/* 12.5px matches .alertbox p span — the established secondary size. */
.ncc-toast .ncc-toast-msg{
  font-size:12.5px;color:var(--dim);line-height:1.5;margin-top:3px;
  overflow-wrap:anywhere;                /* long IDs/URLs must not overflow */
}
/* Optional inline action ("Undo", "Retry"). Underlined, not a button, so it
   never competes with the dismiss control for the role of primary. */
.ncc-toast .ncc-toast-act{
  margin-top:9px;display:inline-flex;align-items:center;gap:6px;
  font-size:12.5px;font-weight:600;color:var(--text);
  background:none;border:0;padding:0;cursor:pointer;font-family:inherit;
  border-bottom:1px solid var(--hair2);
  transition:border-color var(--t-base);
}
.ncc-toast .ncc-toast-act:hover{border-color:var(--text)}

/* Dismiss. 32px visual, but padded to a 44px hit area via ::after so the
   control meets the touch minimum without inflating the toast's height. */
.ncc-toast .ncc-toast-x{
  flex-shrink:0;width:26px;height:26px;margin:-3px -4px 0 0;
  display:flex;align-items:center;justify-content:center;
  border:0;background:transparent;border-radius:8px;cursor:pointer;
  color:var(--mute);font-family:inherit;
  transition:color var(--t-base),background var(--t-base);
}
.ncc-toast .ncc-toast-x::after{
  content:'';position:absolute;inset:auto;
  width:44px;height:44px;
  top:50%;left:50%;transform:translate(-50%,-50%);
}
.ncc-toast .ncc-toast-x:hover{color:var(--text);background:var(--sunk)}
.ncc-toast .ncc-toast-x svg{width:14px;height:14px;stroke:currentColor;
  fill:none;stroke-width:2.2;stroke-linecap:round;pointer-events:none}

/* --- Variants ------------------------------------------------------------ */
.ncc-toast--info{--st-rule:var(--st-rule-info);--st-accent:var(--st-info)}
.ncc-toast--success{--st-rule:var(--st-rule-ok);--st-accent:var(--st-ok)}
.ncc-toast--warning{--st-rule:var(--st-rule-warn);--st-accent:var(--st-warn)}
.ncc-toast--error{
  --st-rule:var(--st-rule-err);
  --st-accent:var(--st-err);
  border-color:var(--st-err-bd);
}
/* Success gets the only tinted ground, because --ok-bg is a token the system
   already owns and success is the one state that benefits from being
   recognisable pre-attentively. */
.ncc-toast--success{background:var(--raised)}

/* --- Motion -------------------------------------------------------------- */
@keyframes ncc-toast-in{
  from{opacity:0;transform:translateY(14px) scale(.97)}
  to{opacity:1;transform:none}
}
@keyframes ncc-toast-out{
  from{opacity:1;transform:none;max-height:200px;margin-bottom:0}
  to{opacity:0;transform:translateY(8px) scale(.97);max-height:0;margin-bottom:-10px}
}
/* JS adds .ncc-out, waits for animationend, then removes the node. Animating
   max-height as well as opacity means the remaining toasts in the stack
   close the gap smoothly instead of jumping. */
.ncc-toast.ncc-out{animation:ncc-toast-out .26s var(--ease) both;pointer-events:none}

/* Auto-dismiss progress hairline. Purely informational — it tells the user
   the toast is on a timer, which stops them reaching to dismiss something
   that is about to leave anyway. */
.ncc-toast .ncc-toast-timer{
  position:absolute;left:0;right:0;bottom:0;height:2px;
  background:var(--st-accent,var(--hair2));opacity:.4;transform-origin:left;
  animation:ncc-timer var(--st-duration,5s) linear both;
}
@keyframes ncc-timer{from{transform:scaleX(1)}to{transform:scaleX(0)}}
/* Hovering or focusing within pauses the timer — standard, and required for
   anyone reading at a slower pace. */
.ncc-toast:hover .ncc-toast-timer,
.ncc-toast:focus-within .ncc-toast-timer{animation-play-state:paused}


/* ==========================================================================
   §3  INLINE ALERTS / BANNERS
   --------------------------------------------------------------------------
   The persistent, in-flow sibling of the toast. This is what every page's
   hand-rolled `.banner` should have been, and what `.alertbox` in
   signin/reset/verify should be replaced by.

   Structure is identical to the toast so the two read as one family:
     icon · title · message · optional action · optional dismiss
   ========================================================================== */

.ncc-alert{
  position:relative;
  display:flex;
  align-items:flex-start;
  gap:var(--st-gap);
  background:var(--st-bg,var(--sunk));
  border:1px solid var(--st-bd,var(--hair));
  border-radius:var(--st-radius);
  padding:var(--st-pad-y) var(--st-pad-x);
  padding-left:calc(var(--st-pad-x) + var(--st-rule,0px));
  overflow:hidden;
  isolation:isolate;
  margin:16px 0;
}
.ncc-alert::before{
  content:'';position:absolute;left:0;top:0;bottom:0;
  width:var(--st-rule,0px);background:var(--st-accent,transparent);z-index:1;
}
.ncc-alert > *{position:relative;z-index:2}

.ncc-alert .ncc-alert-ic{
  width:18px;height:18px;flex-shrink:0;margin-top:1px;
  stroke:var(--st-accent,var(--dim));
  fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;
}
.ncc-alert .ncc-alert-body{flex:1;min-width:0}
.ncc-alert .ncc-alert-title{
  font-size:13.5px;font-weight:600;color:var(--text);line-height:1.5;
}
.ncc-alert .ncc-alert-msg{
  font-size:12.5px;color:var(--dim);line-height:1.55;margin-top:3px;
  text-wrap:pretty;
}
/* When there is no title, the message carries the weight and steps up a
   size so a one-line alert does not look like a footnote. */
.ncc-alert .ncc-alert-msg:only-child{font-size:13px;color:var(--dim)}
.ncc-alert .ncc-alert-act{
  margin-top:10px;display:inline-flex;gap:10px;flex-wrap:wrap;align-items:center;
}

.ncc-alert--info{--st-rule:var(--st-rule-info);--st-accent:var(--st-info);
                 --st-bg:var(--st-info-bg);--st-bd:var(--st-info-bd)}
.ncc-alert--success{--st-rule:var(--st-rule-ok);--st-accent:var(--st-ok);
                    --st-bg:var(--st-ok-bg);--st-bd:var(--st-ok-bd)}
.ncc-alert--warning{--st-rule:var(--st-rule-warn);--st-accent:var(--st-warn);
                    --st-bg:var(--st-warn-bg);--st-bd:var(--st-warn-bd)}
.ncc-alert--error{--st-rule:var(--st-rule-err);--st-accent:var(--st-err);
                  --st-bg:var(--st-err-bg);--st-bd:var(--st-err-bd)}

/* Quiet variant — for the "Prototype — …" notices. Same skeleton, no rule,
   no icon requirement, reads as a margin note rather than an interruption. */
.ncc-alert--quiet{
  --st-rule:0px;background:var(--sunk);border-color:var(--hair);
}
.ncc-alert--quiet .ncc-alert-msg{font-size:12.5px}

/* Dismissible */
.ncc-alert .ncc-alert-x{
  flex-shrink:0;width:26px;height:26px;margin:-2px -4px 0 0;position:relative;
  display:flex;align-items:center;justify-content:center;
  border:0;background:transparent;border-radius:8px;cursor:pointer;
  color:var(--mute);transition:color var(--t-base),background var(--t-base);
}
.ncc-alert .ncc-alert-x::after{
  content:'';position:absolute;width:44px;height:44px;
  top:50%;left:50%;transform:translate(-50%,-50%);
}
.ncc-alert .ncc-alert-x:hover{color:var(--text);background:var(--bg)}
.ncc-alert .ncc-alert-x svg{width:14px;height:14px;stroke:currentColor;
  fill:none;stroke-width:2.2;stroke-linecap:round;pointer-events:none}


/* ==========================================================================
   §4  FORM VALIDATION STATES
   --------------------------------------------------------------------------
   signin.html, signup.html and reset.html already established a contract:

       .field            wrapper
       .field.bad input  invalid
       .field.good input valid
       .field .msg       message, shown by .bad
       .field .tick      success check, shown by .good

   That contract is GOOD and this section does not redefine any of it. It
   adds the four states those pages have no rule for — warning, disabled,
   readonly, required — plus helper text, and it makes the whole set apply
   to <textarea> and <select>, which the existing rules miss entirely
   (community.html's composer and live.html's select are unstyled by it).
   ========================================================================== */

/* --- 4.1 Extend the existing contract to textarea + select ---------------- */
.field.bad textarea,.field.bad select{
  border-color:var(--st-err-bd);background:var(--st-err-bg);
}
.field.good textarea,.field.good select{border-color:var(--st-ok-bd)}

/* --- 4.2 The missing warning state ---------------------------------------
   "Accepted, but check this" — e.g. a weak-but-legal password, an email at a
   domain we cannot deliver to. Distinct from error: the value is not
   rejected. Carried by the border weight, not by hue. */
.field.warn input,.field.warn textarea,.field.warn select{
  border-color:var(--hair2);
  box-shadow:inset 2px 0 0 var(--st-warn);
}
.field.warn .ncc-msg{display:block;color:var(--dim)}
.field.warn .tick{opacity:0}

/* --- 4.3 Helper and message text ------------------------------------------
   .ncc-help is the always-visible instruction. .ncc-msg is the conditional
   validation message. Both sit at 12px to match the established `.field .msg`
   and `.hint` sizes on the auth pages. */
.ncc-help{
  font-size:12px;color:var(--mute);line-height:1.5;margin-top:8px;
  text-wrap:pretty;
}
.ncc-msg{
  display:none;font-size:12px;line-height:1.5;margin-top:8px;
  align-items:flex-start;gap:6px;
}
.field.bad  .ncc-msg{display:flex;color:var(--st-err)}
.field.good .ncc-msg--ok{display:flex;color:var(--ok)}
.ncc-msg svg{width:13px;height:13px;flex-shrink:0;margin-top:2px;
  stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round}
/* When a field shows an error the helper text is redundant noise — the
   error message supersedes it. */
.field.bad .ncc-help{display:none}

/* --- 4.4 Required marker --------------------------------------------------
   An asterisk alone is not an accessible required indicator; it must be
   paired with `required` + `aria-required` on the control, and the legend
   below should appear once per form. The marker is aria-hidden because the
   control already announces its own required state. */
.ncc-req{color:var(--st-err);margin-left:3px;font-weight:600}
.ncc-req-note{font-size:12px;color:var(--mute);margin-top:6px}
/* Inverse convention, for forms where most fields are required: mark the
   few that are not. Almost always the better choice. */
.ncc-opt{color:var(--mute);margin-left:6px;font-weight:400;font-size:11.5px;
  letter-spacing:.02em}

/* --- 4.5 Disabled and readonly -------------------------------------------
   Disabled = you cannot act on this now. Readonly = this is real data you
   may read and copy but not change. They must not look identical, and
   readonly must NOT dim, because dimming implies "inactive" and readonly
   content is live content.

   The platform currently expresses disabled as inline `opacity:.45`
   (dashboard.html's locked tiles) which drops --dim to roughly 2.1:1
   against --raised. That is a contrast failure. This is the replacement:
   50% opacity is reserved for the *icon*, while the label holds --mute,
   which stays legible. */
input:disabled,textarea:disabled,select:disabled,
.ncc-disabled{
  background:var(--sunk);
  color:var(--mute);
  cursor:not-allowed;
  border-color:var(--hair);
}
input:disabled::placeholder,textarea:disabled::placeholder{color:var(--mute);opacity:.7}

input:read-only:not([type=checkbox]):not([type=radio]),
textarea:read-only,
.ncc-readonly{
  background:var(--sunk);
  border-color:var(--hair);
  color:var(--text);          /* full contrast: this is real, readable data */
  cursor:default;
}
input:read-only:focus,textarea:read-only:focus{border-color:var(--hair2)}

/* Disabled buttons. `.btn[disabled]{opacity:.5}` exists only inside
   signin.html's <style>; this promotes it to the platform and fixes the
   contrast problem by keeping the border and using --mute for the label
   rather than fading the whole control to 50%. */
.btn:disabled,.btn[disabled],.btn[aria-disabled="true"]{
  background:var(--sunk);
  color:var(--mute);
  border-color:var(--hair);
  box-shadow:none;
  cursor:not-allowed;
  transform:none;
  pointer-events:auto;        /* keep it hoverable so the title/tooltip works */
}
.btn:disabled:hover,.btn[disabled]:hover,.btn[aria-disabled="true"]:hover{
  transform:none;box-shadow:none;border-color:var(--hair);
}
.btn.solid:disabled,.btn.solid[disabled],.btn.solid[aria-disabled="true"]{
  background:var(--sunk);color:var(--mute);border-color:var(--hair);
}

/* Fieldset grouping — none of the forms use one, and several should. */
.ncc-fieldset{border:0;padding:0;margin:0 0 22px}
.ncc-legend{
  font-size:12.5px;color:var(--mute);font-weight:500;margin-bottom:10px;
  padding:0;
}

/* Character counter for the community composer. Turns urgent near the cap. */
.ncc-count{
  font-size:11.5px;color:var(--mute);font-variant-numeric:tabular-nums;
  margin-left:auto;
}
.ncc-count.ncc-count--near{color:var(--dim);font-weight:500}
.ncc-count.ncc-count--over{color:var(--st-err);font-weight:600}


/* ==========================================================================
   §5  MODAL / DIALOG
   --------------------------------------------------------------------------
   Two presentations of one component:
     desktop — centred panel
     mobile  — bottom sheet, because a centred panel on a phone puts its
               actions in the middle of the screen, out of thumb reach, and
               fights the on-screen keyboard.

   The platform already has one modal-shaped thing: .cmdk (z-index 100). This
   sits just below it at 95, so the command palette can always open over a
   dialog — correct, since ⌘K is a global escape hatch.

   Works with <dialog> or with a plain div + role="dialog". Both are styled.
   ========================================================================== */

.ncc-modal{
  position:fixed;inset:0;z-index:var(--st-z-modal);
  display:none;
  align-items:center;justify-content:center;
  padding:24px;
  padding-bottom:calc(24px + var(--st-safe-b));
}
.ncc-modal.ncc-open{display:flex}

/* Backdrop. color-mix on --bg matches the treatment .cmdk already uses, so
   both themes get a backdrop that reads as "the room dimmed" rather than
   "a grey sheet". */
.ncc-modal-backdrop{
  position:absolute;inset:0;
  background:color-mix(in srgb,var(--bg) 55%,transparent);
  backdrop-filter:blur(8px);
  -webkit-backdrop-filter:blur(8px);
  animation:ncc-fade .22s var(--ease) both;
}
@keyframes ncc-fade{from{opacity:0}to{opacity:1}}

.ncc-modal-panel{
  position:relative;
  width:100%;max-width:460px;
  max-height:calc(100dvh - 48px);
  display:flex;flex-direction:column;
  background:var(--raised);
  border:1px solid var(--hair2);
  border-radius:18px;                 /* matches .cmdbox */
  box-shadow:var(--sh-lg);
  overflow:hidden;
  animation:ncc-panel-in .3s var(--spring) both;
}
@keyframes ncc-panel-in{
  from{opacity:0;transform:translateY(-8px) scale(.97)}
  to{opacity:1;transform:none}
}
.ncc-modal-panel--wide{max-width:640px}

.ncc-modal-head{
  padding:22px 24px 0;
  display:flex;align-items:flex-start;gap:14px;
}
.ncc-modal-head .ncc-modal-ic{
  width:40px;height:40px;border-radius:12px;flex-shrink:0;
  background:var(--sunk);border:1px solid var(--hair);
  display:flex;align-items:center;justify-content:center;color:var(--text);
}
.ncc-modal-title{
  font-family:var(--serif);font-weight:400;font-size:21px;
  letter-spacing:-.014em;line-height:1.24;color:var(--text);
  text-wrap:balance;
}
.ncc-modal-sub{
  font-size:14px;color:var(--dim);line-height:1.58;margin-top:7px;
  text-wrap:pretty;
}
/* The scrollable region. Body scroll is locked (see .ncc-scroll-lock) so
   this is the only thing that moves. */
.ncc-modal-body{
  padding:18px 24px 0;
  overflow-y:auto;
  overscroll-behavior:contain;        /* no scroll chaining to the page */
  flex:1;
  -webkit-overflow-scrolling:touch;
}
.ncc-modal-body p{font-size:14.5px;color:var(--dim);line-height:1.62}

/* Footer actions. Reverse order in the DOM is NOT used — the primary action
   is last in source and last visually, which matches every other confirm
   surface on the platform and keeps tab order = visual order. */
.ncc-modal-foot{
  padding:20px 24px calc(22px + var(--st-safe-b));
  display:flex;gap:10px;justify-content:flex-end;align-items:center;
  flex-wrap:wrap;
  border-top:1px solid var(--hair);
  margin-top:20px;
  background:var(--raised);
}
.ncc-modal-foot--split{justify-content:space-between}
/* Below 380px two pill buttons will not sit side by side without one of them
   wrapping mid-word. Stack them and let the primary take the top slot. */
@media(max-width:400px){
  .ncc-modal-foot{flex-direction:column-reverse;align-items:stretch}
  .ncc-modal-foot .btn{width:100%}
}

.ncc-modal-x{
  position:absolute;top:14px;right:14px;
  width:34px;height:34px;
  display:flex;align-items:center;justify-content:center;
  border:0;background:transparent;border-radius:10px;cursor:pointer;
  color:var(--mute);z-index:3;
  transition:color var(--t-base),background var(--t-base);
}
.ncc-modal-x::after{content:'';position:absolute;width:44px;height:44px;
  top:50%;left:50%;transform:translate(-50%,-50%)}
.ncc-modal-x:hover{color:var(--text);background:var(--sunk)}
.ncc-modal-x svg{width:15px;height:15px;stroke:currentColor;fill:none;
  stroke-width:2.2;stroke-linecap:round;pointer-events:none}

/* --- 5.1 Bottom sheet (mobile) -------------------------------------------
   Below 640px the panel leaves the centre and becomes a sheet: full width,
   pinned to the bottom, square-cornered at the base. The grab handle is a
   visual affordance for the drag-to-dismiss gesture the JS may implement;
   it is inert if not wired, and harmless. */
@media(max-width:640px){
  .ncc-modal{padding:0;align-items:flex-end}
  .ncc-modal-panel{
    max-width:none;
    border-radius:20px 20px 0 0;
    border-bottom:0;
    max-height:min(88dvh,calc(100dvh - 40px));
    animation:ncc-sheet-in .34s var(--ease) both;
    padding-bottom:var(--st-safe-b);
  }
  @keyframes ncc-sheet-in{
    from{transform:translateY(100%)}
    to{transform:none}
  }
  .ncc-modal-panel::before{
    content:'';position:absolute;top:8px;left:50%;transform:translateX(-50%);
    width:38px;height:4px;border-radius:99px;background:var(--hair2);z-index:4;
  }
  .ncc-modal-head{padding-top:26px}
  .ncc-modal-x{top:20px}
  .ncc-modal-foot{padding-bottom:calc(20px + var(--st-safe-b))}
}
/* Opt out of the sheet on a per-dialog basis. */
@media(max-width:640px){
  .ncc-modal--centred{align-items:center;padding:20px}
  .ncc-modal--centred .ncc-modal-panel{
    border-radius:18px;border-bottom:1px solid var(--hair2);
    animation:ncc-panel-in .3s var(--spring) both;
  }
  .ncc-modal--centred .ncc-modal-panel::before{display:none}
}

/* --- 5.2 Scroll lock -----------------------------------------------------
   Applied to <body> by the JS in §14. `position:fixed` is deliberately NOT
   used — it loses scroll position on iOS. overflow:hidden plus a
   scrollbar-gutter reservation avoids the layout shift that otherwise
   happens on desktop when the scrollbar disappears. */
body.ncc-scroll-lock{
  overflow:hidden;
  scrollbar-gutter:stable;
  touch-action:none;
}

/* --- 5.3 Focus trap styling ----------------------------------------------
   The trap itself is JS (§14). What CSS owns is making the trapped region
   legible: the sentinel elements must be reachable by the tab sequence but
   invisible, and anything outside the modal must be inert to the pointer. */
.ncc-focus-sentinel{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;
}
.ncc-modal[aria-hidden="true"]{display:none}
/* When a modal is open the rest of the document should not respond to the
   pointer even before JS sets `inert`, which older browsers ignore. */
body.ncc-scroll-lock > *:not(.ncc-modal):not(.ncc-toasts):not(script){
  pointer-events:none;
}
body.ncc-scroll-lock .ncc-modal,
body.ncc-scroll-lock .ncc-toasts{pointer-events:auto}


/* ==========================================================================
   §6  EMPTY STATES
   --------------------------------------------------------------------------
   Three sizes, one structure. dashboard.html already has a page-local
   `.empty` for the "Nothing here yet" card; this is the platform version,
   namespaced so it does not collide with it.

   An empty state has four parts and the fourth is the one that always gets
   dropped: icon, headline, explanation, ACTION. An empty state without an
   action is a dead end. Where genuinely no action exists, say when the thing
   will appear — dashboard.html does this well ("The first is coming this
   week") and that pattern is preserved in .ncc-empty-note.
   ========================================================================== */

.ncc-empty{
  text-align:center;
  display:flex;flex-direction:column;align-items:center;
  padding:clamp(28px,4vw,40px) 24px;
}
.ncc-empty-ic{
  width:44px;height:44px;border-radius:13px;
  background:var(--sunk);border:1px solid var(--hair);
  display:flex;align-items:center;justify-content:center;
  color:var(--mute);margin-bottom:16px;flex-shrink:0;
}
.ncc-empty-ic svg{width:20px;height:20px;stroke:currentColor;fill:none;
  stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.ncc-empty-title{
  font-family:var(--serif);font-weight:400;font-size:21px;
  letter-spacing:-.012em;line-height:1.25;color:var(--text);
  text-wrap:balance;
}
.ncc-empty-msg{
  color:var(--dim);font-size:14.5px;line-height:1.6;margin-top:9px;
  max-width:36ch;text-wrap:pretty;
}
.ncc-empty-act{margin-top:20px;display:flex;gap:10px;flex-wrap:wrap;justify-content:center}
.ncc-empty-note{
  color:var(--mute);font-size:12.5px;margin-top:14px;line-height:1.55;
  max-width:38ch;
}

/* Small — inside a card or a column of a .grid2. */
.ncc-empty--sm{padding:24px 18px}
.ncc-empty--sm .ncc-empty-ic{width:34px;height:34px;border-radius:11px;margin-bottom:12px}
.ncc-empty--sm .ncc-empty-ic svg{width:16px;height:16px}
.ncc-empty--sm .ncc-empty-title{font-size:16px;font-family:var(--sans);font-weight:600;letter-spacing:-.012em}
.ncc-empty--sm .ncc-empty-msg{font-size:13.5px;margin-top:7px;max-width:32ch}
.ncc-empty--sm .ncc-empty-act{margin-top:15px}

/* Large — a whole page region with nothing in it (Launches before the first
   launch, Community before the first thread). */
.ncc-empty--lg{padding:clamp(48px,7vw,80px) 24px}
.ncc-empty--lg .ncc-empty-ic{width:56px;height:56px;border-radius:16px;margin-bottom:22px}
.ncc-empty--lg .ncc-empty-ic svg{width:26px;height:26px}
.ncc-empty--lg .ncc-empty-title{font-size:clamp(23px,3vw,29px);letter-spacing:-.016em}
.ncc-empty--lg .ncc-empty-msg{font-size:15.5px;margin-top:12px;max-width:44ch}
.ncc-empty--lg .ncc-empty-act{margin-top:26px}


/* ==========================================================================
   §7  LOADING
   --------------------------------------------------------------------------
   Three scales: the control, the region, the page.

   The rule that matters: a loading state must not change the size of the
   thing that is loading. signin.html currently does
   `btn.textContent = 'Signing in…'` on a pill button, which changes its
   width mid-interaction and shifts everything after it. §7.1 fixes that by
   keeping the label in place and hiding it, so the button holds its box.
   ========================================================================== */

/* --- 7.1 Button pending state -------------------------------------------- */
.btn.ncc-pending{
  position:relative;
  color:transparent !important;      /* label reserves the width, invisibly */
  pointer-events:none;
  cursor:progress;
}
.btn.ncc-pending::after{
  content:'';
  position:absolute;top:50%;left:50%;
  width:15px;height:15px;margin:-7.5px 0 0 -7.5px;
  border-radius:50%;
  border:2px solid color-mix(in srgb,currentColor 25%,transparent);
  border-top-color:var(--text);
  animation:ncc-spin .62s linear infinite;
}
/* On a solid button the spinner must invert with the ground. */
.btn.solid.ncc-pending::after{
  border-color:color-mix(in srgb,var(--btn-fg) 28%,transparent);
  border-top-color:var(--btn-fg);
}
@keyframes ncc-spin{to{transform:rotate(360deg)}}

/* --- 7.2 Inline spinner --------------------------------------------------- */
.ncc-spinner{
  display:inline-block;width:15px;height:15px;flex-shrink:0;
  border-radius:50%;
  border:2px solid var(--hair2);
  border-top-color:var(--text);
  animation:ncc-spin .62s linear infinite;
  vertical-align:-2px;
}
.ncc-spinner--sm{width:12px;height:12px;border-width:1.5px}
.ncc-spinner--lg{width:24px;height:24px;border-width:2.5px}

/* A labelled loading row — for a section that is fetching. */
.ncc-loading{
  display:flex;align-items:center;justify-content:center;gap:10px;
  padding:34px 20px;color:var(--mute);font-size:13.5px;
}

/* --- 7.3 Page-level progress bar -----------------------------------------
   Sits at the very top of the viewport. lesson.html already has `.rprog`
   (a reading-progress bar) at z-index 80 in the same position — this is a
   different thing and must not be used on that page at the same time.
   Indeterminate by default; add an inline `--st-progress` for determinate. */
.ncc-pagebar{
  position:fixed;left:0;right:0;top:0;height:2px;z-index:var(--st-z-toast);
  background:transparent;overflow:hidden;pointer-events:none;
}
.ncc-pagebar::before{
  content:'';position:absolute;inset:0;
  background:var(--text);
  transform-origin:left;
  animation:ncc-pagebar 1.4s var(--ease) infinite;
}
@keyframes ncc-pagebar{
  0%{transform:translateX(-100%) scaleX(.35)}
  50%{transform:translateX(0) scaleX(.6)}
  100%{transform:translateX(100%) scaleX(.35)}
}
.ncc-pagebar--determinate::before{
  animation:none;
  transform:scaleX(var(--st-progress,0));
  transition:transform .28s var(--ease);
}

/* --- 7.4 Skeletons -------------------------------------------------------
   For content whose shape is known before its values are. Uses --sunk to
   --hair to --sunk so it works in both themes without a hardcoded gradient. */
.ncc-skel{
  background:linear-gradient(90deg,var(--sunk) 25%,var(--hair) 37%,var(--sunk) 63%);
  background-size:400% 100%;
  animation:ncc-skel 1.4s ease-in-out infinite;
  border-radius:var(--r-sm);
  color:transparent;
  user-select:none;
  pointer-events:none;
}
@keyframes ncc-skel{from{background-position:100% 50%}to{background-position:0 50%}}
.ncc-skel--text{height:1em;margin:.35em 0;border-radius:5px}
.ncc-skel--title{height:1.4em;width:60%;border-radius:6px}
.ncc-skel--circle{border-radius:50%}


/* ==========================================================================
   §8  ERROR STATES
   --------------------------------------------------------------------------
   Three scales again: the field, the section, the page. Every one of them
   answers the same three questions — what failed, why, and what the user
   can do next. The third is the one interfaces routinely omit, which is why
   every variant here has a retry affordance built into its structure.
   ========================================================================== */

/* --- 8.1 Inline (field / row level) --------------------------------------- */
.ncc-error-inline{
  display:flex;align-items:flex-start;gap:7px;
  font-size:12.5px;color:var(--st-err);line-height:1.5;margin-top:8px;
}
.ncc-error-inline svg{width:13px;height:13px;flex-shrink:0;margin-top:2px;
  stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round}

/* --- 8.2 Section error, with retry ---------------------------------------
   Occupies the space the failed content would have taken, so the page does
   not collapse and reflow when a module fails to load. */
.ncc-error-section{
  display:flex;flex-direction:column;align-items:center;text-align:center;
  padding:clamp(32px,4.5vw,48px) 24px;
  border:1px dashed var(--hair2);
  border-radius:var(--r-lg);
  background:var(--surface);
  min-height:180px;justify-content:center;
}
.ncc-error-section .ncc-error-ic{
  width:40px;height:40px;border-radius:12px;
  background:var(--sunk);border:1px solid var(--hair);
  display:flex;align-items:center;justify-content:center;
  color:var(--st-err);margin-bottom:15px;
}
.ncc-error-section .ncc-error-ic svg{width:19px;height:19px;stroke:currentColor;
  fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.ncc-error-title{
  font-size:16px;font-weight:600;color:var(--text);letter-spacing:-.012em;
  line-height:1.4;text-wrap:balance;
}
.ncc-error-msg{
  font-size:13.5px;color:var(--dim);line-height:1.58;margin-top:8px;
  max-width:40ch;text-wrap:pretty;
}
.ncc-error-section .btn{margin-top:18px}
/* The technical detail, for anyone who wants it. Collapsed by default so it
   never becomes the loudest thing in the block. */
.ncc-error-detail{margin-top:14px;max-width:52ch;width:100%}
.ncc-error-detail summary{
  font-size:12px;color:var(--mute);cursor:pointer;list-style:none;
  padding:6px 10px;border-radius:8px;display:inline-block;
  transition:color var(--t-base),background var(--t-base);
}
.ncc-error-detail summary::-webkit-details-marker{display:none}
.ncc-error-detail summary:hover{color:var(--dim);background:var(--sunk)}
.ncc-error-detail pre{
  margin-top:10px;padding:12px 14px;border-radius:var(--r-sm);
  background:var(--sunk);border:1px solid var(--hair);
  font-size:11.5px;line-height:1.6;color:var(--dim);
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  text-align:left;overflow-x:auto;white-space:pre-wrap;overflow-wrap:anywhere;
}

/* --- 8.3 Full-page error --------------------------------------------------
   Structurally the same as 404.html's `.box`, so the two feel like the same
   product. Centred, generous, one clear primary action. */
.ncc-error-page{
  min-height:60vh;min-height:60dvh;
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  text-align:center;padding:clamp(48px,8vw,90px) 24px;
}
.ncc-error-page .ncc-error-code{
  display:inline-flex;align-items:center;gap:10px;
  border:1px solid var(--hair);background:var(--sunk);
  border-radius:var(--r-pill);padding:6px 14px;color:var(--mute);
  font-size:12px;letter-spacing:.2em;text-transform:uppercase;font-weight:500;
}
.ncc-error-page h1{
  font-family:var(--serif);font-weight:400;
  font-size:clamp(30px,5vw,48px);line-height:1.08;letter-spacing:-.022em;
  margin-top:22px;text-wrap:balance;
}
.ncc-error-page .ncc-error-msg{
  font-size:clamp(15px,1.7vw,17px);margin-top:18px;max-width:46ch;
}
.ncc-error-page .ncc-empty-act{margin-top:28px}


/* ==========================================================================
   §9  OFFLINE / CONNECTION BANNER
   --------------------------------------------------------------------------
   Pinned to the top, below the sticky nav (z 60) so it never covers the
   logo or the theme switch, and it pushes nothing — it overlays, because a
   connection banner that reflows the page causes a scroll jump exactly when
   the user is least able to tolerate one.
   ========================================================================== */

.ncc-offline{
  position:fixed;
  left:0;right:0;
  top:calc(var(--st-safe-t));
  z-index:var(--st-z-tooltip);
  display:flex;align-items:center;justify-content:center;gap:9px;
  padding:9px 16px;
  padding-left:calc(16px + var(--st-safe-l));
  padding-right:calc(16px + var(--st-safe-r));
  background:var(--text);
  color:var(--bg);
  font-size:12.5px;font-weight:500;line-height:1.4;
  text-align:center;
  transform:translateY(-100%);
  transition:transform .32s var(--ease);
}
.ncc-offline.ncc-on{transform:none}
.ncc-offline svg{width:15px;height:15px;flex-shrink:0;stroke:currentColor;
  fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
/* The reconnected confirmation — same bar, inverted emphasis, auto-hides. */
.ncc-offline--back{background:var(--ok);color:#FFFFFF}
.ncc-offline .ncc-offline-act{
  background:none;border:0;color:inherit;font-family:inherit;font-size:12.5px;
  font-weight:600;text-decoration:underline;text-underline-offset:2px;
  cursor:pointer;padding:4px 6px;
}


/* ==========================================================================
   §10  TOOLTIP
   --------------------------------------------------------------------------
   CSS-only, driven by :hover and :focus-visible on the trigger. Uses a data
   attribute for the text so no extra markup is needed.

       <button class="ncc-tip" data-tip="Available when the app launches">

   IMPORTANT ACCESSIBILITY NOTE
   ----------------------------
   A tooltip is not an accessible name. dashboard.html currently relies on
   `title="Available when the app launches"` for its four disabled tiles,
   which is invisible on touch, unreachable by keyboard in most browsers,
   and inconsistently announced. Pair this with aria-describedby, or use
   §3's alert instead. This component is for supplementary detail only.
   ========================================================================== */

.ncc-tip{position:relative}
.ncc-tip::after,
.ncc-tip::before{
  opacity:0;pointer-events:none;
  transition:opacity var(--t-base) var(--ease),transform var(--t-base) var(--ease);
}
.ncc-tip::after{
  content:attr(data-tip);
  position:absolute;bottom:calc(100% + 9px);left:50%;
  transform:translateX(-50%) translateY(4px);
  z-index:var(--st-z-tooltip);
  background:var(--text);color:var(--bg);
  font-size:12px;font-weight:500;line-height:1.45;letter-spacing:normal;
  text-transform:none;text-align:center;
  padding:7px 10px;border-radius:9px;
  width:max-content;max-width:220px;
  box-shadow:var(--sh-md);
  white-space:normal;
}
/* The pointer. Same colour source as the bubble so they can never drift. */
.ncc-tip::before{
  content:'';position:absolute;bottom:calc(100% + 4px);left:50%;
  transform:translateX(-50%) translateY(4px);
  z-index:var(--st-z-tooltip);
  border:5px solid transparent;border-top-color:var(--text);
}
.ncc-tip:hover::after,.ncc-tip:hover::before,
.ncc-tip:focus-visible::after,.ncc-tip:focus-visible::before{
  opacity:1;transform:translateX(-50%) translateY(0);
}
/* Placement variants, for triggers near a viewport edge. */
.ncc-tip--below::after{bottom:auto;top:calc(100% + 9px)}
.ncc-tip--below::before{bottom:auto;top:calc(100% + 4px);
  border-top-color:transparent;border-bottom-color:var(--text)}
.ncc-tip--start::after{left:0;transform:translateX(0) translateY(4px)}
.ncc-tip--start:hover::after,.ncc-tip--start:focus-visible::after{
  transform:translateX(0) translateY(0)}
.ncc-tip--end::after{left:auto;right:0;transform:translateX(0) translateY(4px)}
.ncc-tip--end:hover::after,.ncc-tip--end:focus-visible::after{
  transform:translateX(0) translateY(0)}

/* Touch devices have no hover. Suppress rather than show a tooltip that
   would be triggered by a tap and then stick. */
@media(hover:none){
  .ncc-tip::after,.ncc-tip::before{display:none}
}


/* ==========================================================================
   §11  CONFIRMATION / DESTRUCTIVE ACTION
   --------------------------------------------------------------------------
   settings.html currently confirms account deletion by relabelling the
   button to "Click again to confirm". That is the weakest possible
   safeguard for the most irreversible action in the product: it is one
   double-click away from data loss, it gives no information about what will
   be destroyed, and it is not announced to a screen reader.

   The replacement is a modal (§5) in the destructive posture below, with a
   typed-confirmation option for the truly irreversible cases.
   ========================================================================== */

.ncc-modal--danger .ncc-modal-ic{
  border-color:var(--st-err-bd);
  color:var(--st-err);
  background:var(--st-err-bg);
}
.ncc-modal--danger .ncc-modal-panel{border-color:var(--st-err-bd)}

/* The destructive button. Outlined by default — a filled destructive button
   competes with the safe default for primacy, and the safe default should
   always win the eye. Fills only on hover, at the moment of commitment. */
.btn.ncc-danger{
  border-color:var(--st-err-bd);
  color:var(--st-err);
  background:transparent;
}
.btn.ncc-danger:hover{
  border-color:var(--st-err);
  background:var(--st-err-bg);
}
.btn.ncc-danger:focus-visible{outline-color:var(--st-err)}
.btn.ncc-danger:disabled,.btn.ncc-danger[disabled]{
  border-color:var(--hair);color:var(--mute);background:var(--sunk);
}

/* A summary of exactly what is about to be destroyed. Never make the user
   remember what they are agreeing to. */
.ncc-confirm-list{
  margin:16px 0 0;padding:14px 16px;
  background:var(--sunk);border:1px solid var(--hair);
  border-radius:var(--r-sm);
  list-style:none;
}
.ncc-confirm-list li{
  font-size:13px;color:var(--dim);line-height:1.6;
  padding-left:16px;position:relative;
}
.ncc-confirm-list li + li{margin-top:5px}
.ncc-confirm-list li::before{
  content:'';position:absolute;left:0;top:.6em;
  width:4px;height:4px;border-radius:50%;background:var(--mute);
}

/* Typed confirmation, for irreversible actions only. */
.ncc-confirm-type{margin-top:18px}
.ncc-confirm-type label{
  display:block;font-size:12.5px;color:var(--mute);margin-bottom:8px;
  font-weight:500;
}
.ncc-confirm-type code{
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size:12.5px;color:var(--text);background:var(--sunk);
  border:1px solid var(--hair);border-radius:6px;padding:2px 6px;
}


/* ==========================================================================
   §12  FOCUS-VISIBLE REFINEMENT + REDUCED MOTION
   --------------------------------------------------------------------------
   app.css sets a global `:focus-visible{outline:2px solid var(--text)}`,
   which is a good default and fails in three specific places:

     1. On a SOLID button or an `.on` tile, the ring is --text on a --text
        ground. It disappears entirely.
     2. On elements with overflow:hidden ancestors (.surf, .tile, .media,
        .cmdbox) a 3px-offset outline is clipped.
     3. On the custom toggle in settings.html the ring lands on the 44x26
        track, which reads as a misfit rectangle around a pill.

   Each is corrected below. Nothing here removes a focus indicator; every
   rule replaces one with a more visible one.
   ========================================================================== */

/* 1. Inverted grounds — draw the ring in the button's own foreground colour
      and add an outer halo in --bg so it is legible against any backdrop. */
.btn.solid:focus-visible,
.tile.on:focus-visible,
.filt button.on:focus-visible,
.ncc-toast .ncc-toast-act:focus-visible{
  outline:2px solid var(--btn-fg);
  outline-offset:-4px;
  box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--text);
}

/* 2. Clipped contexts — pull the ring inside the element. */
.surf a:focus-visible,
.surf button:focus-visible,
.media:focus-visible,
.cmditem:focus-visible,
.th:focus-visible,
.lrow:focus-visible{
  outline-offset:-3px;
}

/* 3. The settings toggle — ring the pill, not the box. */
.sw input:focus-visible + span{
  outline:2px solid var(--text);
  outline-offset:3px;
  border-radius:var(--r-pill);
}

/* Custom controls that currently have NO focus style at all. */
.ncc-toast-x:focus-visible,
.ncc-alert-x:focus-visible,
.ncc-modal-x:focus-visible,
.ncc-error-detail summary:focus-visible{
  outline:2px solid var(--text);outline-offset:2px;border-radius:8px;
}

/* Keyboard users reaching a modal should land on the panel, not the page
   behind it. Give the panel a focusable, non-jarring ring. */
.ncc-modal-panel:focus{outline:none}
.ncc-modal-panel:focus-visible{outline:2px solid var(--text);outline-offset:2px}

/* Skip link — the platform has none. Every page should carry one as the
   first focusable element. */
.ncc-skip{
  position:absolute;left:-9999px;top:0;z-index:200;
  background:var(--raised);color:var(--text);
  border:1px solid var(--hair2);border-radius:0 0 var(--r-sm) 0;
  padding:12px 18px;font-size:14px;font-weight:600;
  box-shadow:var(--sh-md);
}
.ncc-skip:focus{
  left:0;outline:2px solid var(--text);outline-offset:-2px;
}

/* Windows High Contrast / forced-colors. Custom-drawn severity rules and
   backgrounds are stripped by the OS, so restore the boundaries with
   system-keyword borders that forced-colors preserves. */
@media (forced-colors:active){
  .ncc-toast,.ncc-alert,.ncc-modal-panel,.ncc-error-section{
    border:1px solid CanvasText;
  }
  .ncc-toast::before,.ncc-alert::before{forced-color-adjust:none;background:Highlight}
  .btn.ncc-pending::after,.ncc-spinner{border-top-color:Highlight}
  .ncc-offline{background:Canvas;color:CanvasText;border-bottom:1px solid CanvasText}
}

/* --- Reduced motion -------------------------------------------------------
   Everything animated above collapses to an instant state change. Crucially
   the *auto-dismiss timer* is not removed — it is made instant-invisible
   rather than animated — because removing it would leave toasts on screen
   forever for exactly the users least likely to want that. The JS timeout
   in §14 is the real dismissal mechanism; the bar is only its picture. */
@media (prefers-reduced-motion:reduce){
  .ncc-toast,
  .ncc-toast.ncc-out,
  .ncc-modal-backdrop,
  .ncc-modal-panel{
    animation-duration:.01ms !important;
    animation-iteration-count:1 !important;
  }
  .ncc-toast .ncc-toast-timer{animation:none;display:none}
  .btn.ncc-pending::after,
  .ncc-spinner{animation-duration:1.4s}   /* slowed, not stopped: it must
                                             still read as "working" */
  .ncc-pagebar::before{animation:none;transform:scaleX(.4);opacity:.7}
  .ncc-skel{animation:none;background:var(--sunk)}
  .ncc-offline{transition:none}
  .ncc-tip::after,.ncc-tip::before{transition:none}
  @media(max-width:640px){
    .ncc-modal-panel{animation-duration:.01ms !important}
  }
}


/* ==========================================================================
   §13  UTILITIES
   ========================================================================== */

/* Visually hidden but announced — for live regions and control labels. */
.ncc-sr{
  position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);
  white-space:nowrap;border:0;
}
/* …and its focusable variant, for skip links inside components. */
.ncc-sr-focusable:focus,.ncc-sr-focusable:active{
  position:static;width:auto;height:auto;margin:0;overflow:visible;
  clip:auto;clip-path:none;white-space:normal;
}

/* The live region that announces toasts. One per page, empty, never styled
   into visibility. See §14. */
.ncc-live{
  position:absolute;width:1px;height:1px;overflow:hidden;
  clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;
}


/* ==========================================================================
   §14  JS COMPANION — documented, NOT shipped
   --------------------------------------------------------------------------
   app.js is owned elsewhere, so this is the specification rather than the
   implementation. Everything below is dependency-free, ES5-safe, and about
   90 lines. Paste it into app.js (or a new states.js loaded after it).

   ── REQUIRED MARKUP, once per page, before </body> ────────────────────────

     <div class="ncc-toasts" id="ncc-toasts"></div>
     <div class="ncc-live" id="ncc-live" role="status" aria-live="polite"
          aria-atomic="true"></div>

   The toast container is deliberately NOT role="alert". Screen-reader
   announcement is routed through the separate #ncc-live region so the
   visual stack and the announcement can be tuned independently — errors
   announce assertively, confirmations politely.

   ── REQUIRED <link> TAGS, in every page's <head> ──────────────────────────

     <link rel="stylesheet" href="app.css">
     <link rel="stylesheet" href="components.css">
     <link rel="stylesheet" href="effects.css">     <-- currently missing
     <link rel="stylesheet" href="states.css">
     <link rel="stylesheet" href="responsive.css">

   ORDER MATTERS, and so does POSITION: both new files must come AFTER each
   page's inline <style> block, not before it. Page-local rules and these
   files share specificity, so whichever is last wins. Today every page puts
   <style> after the <link>s — so the link tags for states.css and
   responsive.css must be moved to sit immediately before </head>.

   ── TOASTS ────────────────────────────────────────────────────────────────

     var NCCToast = (function(){
       var host = document.getElementById('ncc-toasts');
       var live = document.getElementById('ncc-live');
       var ICONS = {
         success:'M20 6 9 17l-5-5',
         error:  'M12 8v4.5M12 16h.01',
         warning:'M12 9v4M12 17h.01M10.3 3.9 2.4 17.6a1.9 1.9 0 0 0 1.7 2.9h15.8a1.9 1.9 0 0 0 1.7-2.9L13.7 3.9a1.9 1.9 0 0 0-3.4 0Z',
         info:   'M12 16v-4M12 8h.01'
       };
       var RING = { error:'<circle cx="12" cy="12" r="9"/>',
                    info: '<circle cx="12" cy="12" r="9"/>',
                    success:'', warning:'' };

       function show(opts){
         if (!host) return;
         var kind = opts.kind || 'info';
         var ms   = opts.duration == null ? 5000 : opts.duration;

         var el = document.createElement('div');
         el.className = 'ncc-toast ncc-toast--' + kind;
         el.setAttribute('role', 'group');
         el.style.setProperty('--st-duration', ms + 'ms');

         el.innerHTML =
           '<svg class="ncc-toast-ic" viewBox="0 0 24 24" aria-hidden="true">' +
             (RING[kind] || '') + '<path d="' + ICONS[kind] + '"/></svg>' +
           '<div class="ncc-toast-body">' +
             (opts.title ? '<div class="ncc-toast-title"></div>' : '') +
             (opts.message ? '<div class="ncc-toast-msg"></div>' : '') +
           '</div>' +
           '<button class="ncc-toast-x" type="button" aria-label="Dismiss">' +
             '<svg viewBox="0 0 24 24" aria-hidden="true">' +
               '<path d="M18 6 6 18M6 6l12 12"/></svg></button>' +
           (ms ? '<i class="ncc-toast-timer"></i>' : '');

         // textContent, never innerHTML, for anything user- or server-supplied.
         if (opts.title)   el.querySelector('.ncc-toast-title').textContent = opts.title;
         if (opts.message) el.querySelector('.ncc-toast-msg').textContent   = opts.message;

         if (opts.action && opts.onAction) {
           var b = document.createElement('button');
           b.type = 'button';
           b.className = 'ncc-toast-act';
           b.textContent = opts.action;
           b.addEventListener('click', function(){ opts.onAction(); dismiss(el); });
           el.querySelector('.ncc-toast-body').appendChild(b);
         }

         el.querySelector('.ncc-toast-x')
           .addEventListener('click', function(){ dismiss(el); });

         host.appendChild(el);

         // Cap the stack. Four is the most a person will read before the
         // oldest has stopped being true.
         while (host.children.length > 4) dismiss(host.firstElementChild);

         // Announce. Errors interrupt; everything else waits its turn.
         if (live) {
           live.setAttribute('aria-live', kind === 'error' ? 'assertive' : 'polite');
           live.textContent = (opts.title || '') + ' ' + (opts.message || '');
         }

         if (ms) {
           var t = setTimeout(function(){ dismiss(el); }, ms);
           // Pause on hover/focus, matching the CSS timer bar.
           el.addEventListener('mouseenter', function(){ clearTimeout(t); });
           el.addEventListener('focusin',    function(){ clearTimeout(t); });
           el.addEventListener('mouseleave', function(){
             t = setTimeout(function(){ dismiss(el); }, 1800);
           });
         }
         return el;
       }

       function dismiss(el){
         if (!el || el.classList.contains('ncc-out')) return;
         el.classList.add('ncc-out');
         var done = function(){ el.remove(); };
         el.addEventListener('animationend', done, { once:true });
         setTimeout(done, 400);          // fallback if animation never fires
       }

       return {
         show:    show,
         success: function(t,m){ return show({kind:'success',title:t,message:m}); },
         error:   function(t,m){ return show({kind:'error',  title:t,message:m,duration:0}); },
         warning: function(t,m){ return show({kind:'warning',title:t,message:m}); },
         info:    function(t,m){ return show({kind:'info',   title:t,message:m}); },
         dismiss: dismiss
       };
     })();

   NOTE the duration:0 on error — an error toast must not time out. The user
   has to be able to read it, and act on it, at their own pace.

   ── MODALS ────────────────────────────────────────────────────────────────

     var NCCModal = (function(){
       var open = null, lastFocus = null;

       var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),' +
                       'select:not([disabled]),textarea:not([disabled]),' +
                       '[tabindex]:not([tabindex="-1"])';

       function show(el){
         if (typeof el === 'string') el = document.getElementById(el);
         if (!el) return;
         lastFocus = document.activeElement;
         el.classList.add('ncc-open');
         el.removeAttribute('aria-hidden');
         document.body.classList.add('ncc-scroll-lock');
         open = el;

         // Focus the first meaningful control, NOT the close button — the
         // close button is the escape hatch, not the purpose of the dialog.
         var f = el.querySelectorAll(FOCUSABLE);
         var target = el.querySelector('[data-autofocus]') ||
                      el.querySelector('.ncc-modal-body ' + FOCUSABLE) ||
                      el.querySelector('.ncc-modal-panel');
         if (target) {
           if (!target.hasAttribute('tabindex') &&
               !target.matches(FOCUSABLE)) target.setAttribute('tabindex','-1');
           target.focus();
         }
       }

       function hide(){
         if (!open) return;
         open.classList.remove('ncc-open');
         open.setAttribute('aria-hidden','true');
         document.body.classList.remove('ncc-scroll-lock');
         if (lastFocus && lastFocus.focus) lastFocus.focus();  // return focus
         open = null;
       }

       document.addEventListener('keydown', function(e){
         if (!open) return;
         if (e.key === 'Escape') { e.preventDefault(); hide(); return; }
         if (e.key !== 'Tab') return;

         // Focus trap: wrap at both ends of the panel's tab sequence.
         var f = Array.prototype.filter.call(
           open.querySelectorAll(FOCUSABLE),
           function(n){ return n.offsetParent !== null; }
         );
         if (!f.length) return;
         var first = f[0], last = f[f.length - 1];
         if (e.shiftKey && document.activeElement === first) {
           e.preventDefault(); last.focus();
         } else if (!e.shiftKey && document.activeElement === last) {
           e.preventDefault(); first.focus();
         }
       });

       // Click the backdrop to dismiss — but never for .ncc-modal--danger,
       // where an accidental outside click should not cancel a decision the
       // user is part-way through making.
       document.addEventListener('click', function(e){
         if (!open) return;
         if (e.target.classList.contains('ncc-modal-backdrop') &&
             !open.classList.contains('ncc-modal--danger')) hide();
       });

       return { show:show, hide:hide };
     })();

   ── MARKUP CONTRACT FOR A MODAL ───────────────────────────────────────────

     <div class="ncc-modal" id="confirm-delete" aria-hidden="true"
          role="dialog" aria-modal="true" aria-labelledby="cd-t"
          aria-describedby="cd-d">
       <div class="ncc-modal-backdrop"></div>
       <div class="ncc-modal-panel" tabindex="-1">
         <button class="ncc-modal-x" type="button" aria-label="Close">…</button>
         <div class="ncc-modal-head">
           <div class="ncc-modal-ic">…</div>
           <div>
             <h2 class="ncc-modal-title" id="cd-t">Delete your account?</h2>
             <p class="ncc-modal-sub" id="cd-d">This cannot be undone.</p>
           </div>
         </div>
         <div class="ncc-modal-body">
           <ul class="ncc-confirm-list">
             <li>Your lesson progress</li>
             <li>Your community posts and replies</li>
             <li>Your member number (#147) — it will not be reissued</li>
           </ul>
         </div>
         <div class="ncc-modal-foot">
           <button class="btn" type="button" data-close>Keep my account</button>
           <button class="btn ncc-danger" type="button">Delete permanently</button>
         </div>
       </div>
     </div>

   ── BUTTON PENDING STATE ──────────────────────────────────────────────────

     function pending(btn, on){
       btn.classList.toggle('ncc-pending', on);
       btn.disabled = on;
       btn.setAttribute('aria-busy', on ? 'true' : 'false');
     }

   Use this instead of reassigning btn.textContent. The label stays in the
   DOM, the button keeps its width, and aria-busy tells assistive tech the
   control is working rather than broken.

   ── OFFLINE BANNER ────────────────────────────────────────────────────────

     var bar = document.querySelector('.ncc-offline');
     function net(){
       if (!bar) return;
       bar.classList.toggle('ncc-on', !navigator.onLine);
     }
     window.addEventListener('online',  net);
     window.addEventListener('offline', net);
     net();

   ========================================================================== */
