/*
 * portal.css — the portal's own stylesheet, loaded after the vendored Tailwind build.
 *
 * WHY THIS FILE EXISTS
 * The vendored tailwind.css is a PURGED build produced for the sales console: it
 * contains only the utilities that console's markup happened to use. The portal's
 * markup is different, so a handful of utilities it needs are simply absent from that
 * file. Re-running a Tailwind build would mean adding node tooling to a repo that is
 * deliberately code-only, and pulling a fresh CSS from a CDN is banned outright
 * (ZERO external origins). So the gap is closed here, by hand, using Tailwind v3's
 * own values so the two files never disagree.
 *
 * RULE FOR ANYONE EDITING TEMPLATES: after changing markup, re-run the class audit
 * (see app/static/ASSETS.md). A utility that is used but undefined fails silently --
 * the page renders unstyled in exactly one spot and nobody notices until a merchant does.
 *
 * Everything here is either (a) a missing Tailwind v3 utility reproduced faithfully,
 * or (b) a `portal-` prefixed component class that is ours. No third file, no inline
 * <style>, no style attributes: `style-src 'self'` must hold with no hashes.
 */

/* --------------------------------------------------------------------------
 * (a0) Palette tokens.
 *
 * The values ARE Tailwind v3 slate; every one of them is already in the
 * vendored build as a text-, border- or bg- utility. (Written without the
 * globs on purpose: a star followed by a slash closes a CSS comment early, and
 * the rest of this block would then parse as declarations.) Naming them here
 * does not introduce a colour, it removes the hand-typed hexes from sections
 * (b)-(d) so that a palette change is one edit instead of eleven.
 *
 * These are NEUTRAL and load on every page, signed in or out. The BRAND accent
 * is NOT declared here: it stays scoped to body.brand-portal in section (d), so
 * a pre-auth page cannot pick it up (nsp#B213).
 *
 * color-scheme is here rather than in a template because Chrome's auto-dark
 * heuristic will otherwise recolour these values on a device set to dark, and
 * every ratio recorded below is computed against the light values.
 * -------------------------------------------------------------------------- */

:root {
  color-scheme: light;

  --portal-ink:       #0f172a;  /* slate-900  headings, dt labels        17.85:1 on #fff */
  --portal-body:      #1e293b;  /* slate-800  the body colour            14.63:1 on #fff */
  --portal-prose:     #334155;  /* slate-700  running prose              10.35:1 on #fff */
  --portal-muted:     #475569;  /* slate-600  helper text, footer         7.58:1 on #fff */
  --portal-boundary:  #64748b;  /* slate-500  input and control edges     4.76:1 on #fff */
  --portal-rule:      #cbd5e1;  /* slate-300  route connector             1.48:1, decorative only */
  --portal-hairline:  #e2e8f0;  /* slate-200  card edges, dividers        1.23:1, decorative only */
  --portal-ground:    #f8fafc;  /* slate-50   the page ground */
  --portal-surface:   #ffffff;  /* cards, header, footer */
}

/* --------------------------------------------------------------------------
 * (a) Missing Tailwind v3 utilities — values copied from the framework defaults.
 * -------------------------------------------------------------------------- */

/* sizing / spacing */
.max-w-xl { max-width: 36rem; }
.w-40 { width: 10rem; }
.w-48 { width: 12rem; }
.p-5 { padding: 1.25rem; }
.my-8 { margin-top: 2rem; margin-bottom: 2rem; }
.mx-2 { margin-left: 0.5rem; margin-right: 0.5rem; }
.right-0 { right: 0; }
.z-40 { z-index: 40; }
.space-y-8 > :not([hidden]) ~ :not([hidden]) { margin-top: 2rem; margin-bottom: 0; }
.divide-slate-200 > :not([hidden]) ~ :not([hidden]) { border-color: rgb(226 232 240); }

/* Touch targets. ux.md section 5 requires >= 44x44px on every interactive element;
   these two are used on every button, link-button and menu control in the app. */
.min-h-\[44px\] { min-height: 44px; }
.min-w-\[44px\] { min-width: 44px; }

/* colour tokens absent from the console build */
.bg-amber-200 { --tw-bg-opacity: 1; background-color: rgb(253 230 138 / var(--tw-bg-opacity)); }
.border-amber-300 { --tw-border-opacity: 1; border-color: rgb(252 211 77 / var(--tw-border-opacity)); }
.border-emerald-300 { --tw-border-opacity: 1; border-color: rgb(110 231 183 / var(--tw-border-opacity)); }
.border-rose-300 { --tw-border-opacity: 1; border-color: rgb(253 164 175 / var(--tw-border-opacity)); }
.border-rose-400 { --tw-border-opacity: 1; border-color: rgb(251 113 133 / var(--tw-border-opacity)); }
.border-sky-300 { --tw-border-opacity: 1; border-color: rgb(125 211 252 / var(--tw-border-opacity)); }
/* slate-500. Absent from the console build, and the only slate on the ramp that
   clears 3:1 as a component boundary: slate-300 is 1.48:1 on white and slate-400
   is 2.56:1, both below the WCAG 1.4.11 floor for the edge of a control.
   #64748b is 4.76:1 on white and 4.55:1 on the slate-50 page ground. */
.border-slate-500 { --tw-border-opacity: 1; border-color: rgb(100 116 139 / var(--tw-border-opacity)); }
.text-rose-900 { --tw-text-opacity: 1; color: rgb(136 19 55 / var(--tw-text-opacity)); }
.ring-emerald-300 { --tw-ring-opacity: 1; --tw-ring-color: rgb(110 231 183 / var(--tw-ring-opacity)); }
.ring-sky-300 { --tw-ring-opacity: 1; --tw-ring-color: rgb(125 211 252 / var(--tw-ring-opacity)); }

/* focus + state variants. Contrast of the focus ring against both white cards and
   the slate-50 page ground is >= 4.5:1 at slate-900. */
.focus-visible\:outline-slate-900:focus-visible { outline-color: #0f172a; }
.focus-visible\:outline-offset-0:focus-visible { outline-offset: 0px; }
.focus\:ring-slate-900:focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(15 23 42 / var(--tw-ring-opacity)); }
.disabled\:opacity-60:disabled { opacity: 0.6; }

/* The skip link: visually hidden until focused, then a real, visible control.
   Reproduces Tailwind's `not-sr-only` plus the positioning utilities it needs. */
.focus\:not-sr-only:focus {
  position: static;
  width: auto;
  height: auto;
  padding: 0;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}
.focus\:absolute:focus { position: absolute; }
.focus\:left-4:focus { left: 1rem; }
.focus\:top-4:focus { top: 1rem; }
.focus\:z-50:focus { z-index: 50; }
.focus\:rounded-md:focus { border-radius: 0.375rem; }
.focus\:bg-slate-900:focus { --tw-bg-opacity: 1; background-color: rgb(15 23 42 / var(--tw-bg-opacity)); }
.focus\:px-4:focus { padding-left: 1rem; padding-right: 1rem; }
.focus\:py-2:focus { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.focus\:text-sm:focus { font-size: 0.875rem; line-height: 1.25rem; }
.focus\:font-semibold:focus { font-weight: 600; }
.focus\:text-white:focus { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); }

/* responsive variants (Tailwind `sm` = 640px, mobile-first) */
@media (min-width: 640px) {
  .sm\:flex { display: flex; }
  .sm\:hidden { display: none; }
  .sm\:py-12 { padding-top: 3rem; padding-bottom: 3rem; }
  .sm\:w-auto { width: auto; }
  /* The console build kept exactly one responsive size variant, sm:text-2xl, so
     the landing page's H1 could not step at the breakpoint -- one heading size
     for phone and desktop is a template-site tell. Tailwind v3's own values for
     text-4xl. (text-4xl itself is already in the vendored sheet; only the sm:
     variant was missing.) */
  .sm\:text-4xl { font-size: 2.25rem; line-height: 2.5rem; }
}

/* --------------------------------------------------------------------------
 * (b) Portal component classes — ours, not Tailwind's.
 * -------------------------------------------------------------------------- */

/* Lists that carry meaning (requirement bullets, the "what to expect" steps).
   `list-disc`/`list-decimal` are absent from the vendored build, and a bulleted
   list whose bullets vanish reads as a paragraph soup. */
.portal-list { padding-left: 1.25rem; }
ul.portal-list { list-style: disc outside; }
ol.portal-list { list-style: decimal outside; }

/* "What to expect" drawn as a ROUTE -- the one piece of visual identity on the
   public page, and on-brand by construction: waypoints joined by a line.

   Deliberately CSS plus an inline SVG marker rather than an image. It needs no
   raster asset (there is no Waypoint mark yet), it scales at any width instead
   of shipping @2x variants, and an inline SVG is markup rather than script, so
   it survives the `script-src 'self'` policy that silently killed the admin
   confirm dialog.

   The list stays a real <ol>: the steps are ordered CONTENT, not decoration, so
   a screen reader or a text browser still gets the sequence once the graphic is
   gone. The markers carry aria-hidden for the same reason. */
.portal-route { list-style: none; padding-left: 0; margin-left: 0; }
.portal-route > li { position: relative; padding-left: 2.25rem; padding-bottom: 1.5rem; }
.portal-route > li:last-child { padding-bottom: 0; }

/* The connector runs BETWEEN markers rather than through them: it starts below
   the marker and stops short of the next, so the dots read as stops on a route
   rather than beads threaded on a wire. */
.portal-route > li::before {
  content: "";
  position: absolute;
  left: 0.625rem;
  top: 1.6rem;
  bottom: 0.15rem;
  width: 2px;
  border-radius: 1px;
  background: var(--portal-rule);
}
.portal-route > li:last-child::before { display: none; }

.portal-route-marker {
  position: absolute;
  left: 0;
  top: 0.05rem;
  width: 1.375rem;
  height: 1.375rem;
}

/* Nav current-page state. In section (b), NOT the brand section: the error pages
   are signed-in surfaces that are deliberately UNBRANDED (BRANDED_TEMPLATES
   excludes errors/), and the nav renders on them, so hanging this off
   body.brand-portal would drop the state on exactly the pages where a lost
   visitor most needs to know where they are.

   Styled by ATTRIBUTE rather than by a utility class. The vendored Tailwind is
   purged against a different app and a class it does not contain renders as
   nothing; an attribute selector cannot be purged. It also keeps the visual
   state and the accessible state as one thing that cannot drift apart.

   #0f172a on the white header is 17.85:1, up from the siblings' #334155 at
   10.35:1; weight goes 500 -> 600. A bg-slate-100 pill was measured and
   rejected: 1.10:1 against the white header is not perceivable, so it would
   carry no information. A green underline was refused on doctrine -- the brand
   basis lists five places green may appear and a nav rule is not one. */
nav[aria-label="Main"] a[aria-current="page"] {
  color: var(--portal-ink);
  font-weight: 600;
}

/* The mobile nav disclosure. A native <details>, chosen over a scripted menu so it
   still opens when the script layer is dead -- "Sign out" must never be unreachable. */
.portal-menu > summary { list-style: none; }
.portal-menu > summary::-webkit-details-marker { display: none; }
.portal-menu > summary::marker { content: ""; }

/* Alpine is progressive enhancement only. x-cloak keeps the "busy" label hidden
   until Alpine claims it; if Alpine never loads (blocked script, strict CSP), the
   label stays hidden and the button behaves as a plain submit -- which is correct. */
[x-cloak] { display: none !important; }

/* The savings-calculator band picker (nsp#B210). Tappable radio pills, PURE
   CSS: the input is visually hidden but focusable, the label is the target, and
   the checked state is `input:checked + label` — the whole screen works with
   zero JavaScript, which is a hard requirement of the anonymous funnel. The
   input is hidden with a 1px clip, NOT display:none, so keyboard focus and
   screen readers still reach it. */
.portal-band-group { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.portal-band-option input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}
/* The unchecked pill's border is the ONLY thing marking the control: white fill
   on a white card. At the old #cbd5e1 that edge was 1.48:1, below the 3:1 WCAG
   1.4.11 asks of a component boundary, and this is the first control a cold
   visitor touches. --portal-boundary (#64748b) is 4.76:1 on white, 4.55:1 on the
   page ground. The checked state is unaffected: white on #0f172a, 17.85:1. */
.portal-band-option label {
  display: inline-flex;
  min-height: 44px;
  align-items: center;
  border-radius: 0.5rem;
  border: 1px solid var(--portal-boundary);
  background: var(--portal-surface);
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--portal-prose);
  cursor: pointer;
}
.portal-band-option input:checked + label {
  background: var(--portal-ink);
  border-color: var(--portal-ink);
  color: var(--portal-surface);
}
.portal-band-option input:focus-visible + label {
  outline: 2px solid var(--portal-ink);
  outline-offset: 2px;
}

/* forced-colors overrides background-color, border-color and color -- which is
   the WHOLE of the checked state above, so in Windows High Contrast (and the
   Android and Chrome equivalents) the chosen band and the unchosen ones render
   identically. The underlying radio is clipped to 1px so it stays focusable, so
   there is no native control left to fall back to, and the number on the next
   screen depends on which pill is selected. `Highlight` is a system colour and
   is preserved by definition, so the outline survives where the fill does not.
   No ratio applies: system colours come from the user's own theme.
   WCAG 1.4.1. */
@media (forced-colors: active) {
  .portal-band-option input:checked + label {
    outline: 3px solid Highlight;
    outline-offset: -3px;
  }
}

/* The invalid field state, keyed on the ATTRIBUTE that already carries it rather
   than on a second Jinja-toggled class, so the visual error and the accessible
   error are one thing and cannot drift. The class it replaces was border-rose-400
   (#fb7185), 2.69:1 on white -- below the 3:1 WCAG 1.4.11 asks of a control
   boundary, on the field that is telling you something went wrong. #be123c
   (rose-700) is 6.29:1 on white and 6.01:1 on the page ground.

   An attribute selector also cannot be purged out of the vendored build. */
input[aria-invalid="true"],
select[aria-invalid="true"],
textarea[aria-invalid="true"] { border-color: #be123c; }

/* The honeypot field (nsp#B210 abuse posture). Moved off-canvas rather than
   display:none — some form-filling bots skip fields a layout engine reports as
   invisible, and the label tells a screen-reader user to leave it blank. */
.portal-hp {
  position: absolute !important;
  left: -9999px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* --------------------------------------------------------------------------
 * Landing page rhythm (index.html).
 *
 * The main column sits on the #f8fafc page ground set by
 * <html class="bg-slate-50">; there is no white surface in <main>. That is why
 * the rule colours here are darker than the border-slate-200 used on the header
 * and footer, which sit on white. #e2e8f0 measures 1.18:1 against #f8fafc -- a
 * 1px line at 1.18:1 is not a visible line, so the page's two structural
 * dividers were in practice 64px of blank space. Measured 20260910 with the
 * WCAG relative-luminance formula. Both rules below are decorative -- the
 * structure is carried by the h2s -- so no WCAG minimum applies; the change is
 * about being seen at all.
 * -------------------------------------------------------------------------- */

/* A section boundary that belongs to the section BELOW it: 40px of air above
   the rule, 24px between the rule and the heading it introduces. The symmetric
   `hr class="my-8"` it replaces read as a break between equals, which is what
   made the CTA block, the trust band and the route read as three unrelated
   slabs. #94a3b8 is 2.45:1 on #f8fafc -- a definite hairline, not a heavy rule.
   If it renders heavier than wanted, #b1b4b6 (1.99:1, gov.uk's own section
   break) is the next step down and is a one-value edit. */
.portal-section { margin-top: 2.5rem; border-top: 1px solid #94a3b8; padding-top: 1.5rem; }

/* The trust band as a fee schedule rather than a run of paragraphs: a hairline
   between rows, 16px either side, no rule above the first row and no padding
   below the last. #cbd5e1 (1.42:1) rather than #94a3b8 deliberately -- these
   dividers GROUP, they do not divide the page, and they must not compete with
   the section rules above. :last-child rather than a count, so an unset
   PORTAL_SUPPORT_EMAIL (which drops the final row) leaves no trailing 16px. */
.portal-facts > div { border-top: 1px solid var(--portal-rule); padding-top: 1rem; padding-bottom: 1rem; }
.portal-facts > div:first-child { border-top: 0; padding-top: 0; }
.portal-facts > div:last-child { padding-bottom: 0; }

/* The route markers take their ink from `color` so a later palette move is a
   value change rather than a template sweep -- the same reason section (d)
   exists. The knockout inside the completed marker stays a literal #ffffff: it
   is a hole, not a theme colour. #0f172a on the #f8fafc ground is 17.06:1;
   both markers are aria-hidden decoration beside labelled text, so no minimum
   binds them. */
.portal-route-marker { color: var(--portal-ink); }

/* --------------------------------------------------------------------------
 * (c2) Composition — the landing page as a product, not a column (20260910).
 *
 * A cross-family look-and-feel review (k3, over rendered screenshots at 1280
 * and 390) named ONE change as the highest-leverage on the site: at desktop
 * the page was a 768px column with the right 40 percent empty, and every
 * section was the same full-width hairline-divided text block. Composition,
 * not imagery, is the fix -- the route graphic is the one ownable visual and
 * it was the last thing on the page. Everything here is plain CSS keyed on
 * classes the templates already carry or that are defined ONLY here; no new
 * Tailwind utility is used, so the purged sheet needs no rebuild
 * (tests/unit/test_tailwind_class_coverage.py reads this file as ground truth).
 * -------------------------------------------------------------------------- */

/* The landing page alone runs wider than the 768px app column. Header and
   footer inner containers widen with it so the chrome does not read as a
   narrower page sitting on a wider one. body.portal-landing is set by
   index.html's body_class block and by nothing else. */
@media (min-width: 1024px) {
  body.portal-landing main > div,
  body.portal-landing header > div,
  body.portal-landing footer > div { max-width: 68rem; }
}

/* The hero: copy left, the route on a tinted panel right, from 1024px. Below
   that it is a single column and the panel stacks UNDER the CTAs, so the
   sign-in button stays near the fold on a phone (the invitation email is the
   entry point). */
.portal-hero { display: grid; gap: 2rem; }
@media (min-width: 1024px) {
  .portal-hero { grid-template-columns: 1.05fr 0.95fr; gap: 4rem; align-items: center; }
}
/* A grid child defaults to min-width:auto and can refuse to shrink below its
   longest unbreakable line (a long email address in the copy, say); min-width:0
   lets the column track win. */
.portal-hero-copy { min-width: 0; }
/* The sections below the hero keep the 768px reading measure inside the wider
   landing container: a 68rem line of body text is not a line anyone reads. */
.portal-landing-body { max-width: 48rem; }

/* The panel ground is a green-leaning neutral, not the accent: #f0f5f1 keeps
   the route's #0f172a ink at 16.2:1 and reads as the brand without shouting.
   The edge is a tint of the same family so the panel has a boundary on the
   #f8fafc page ground (the two are 1.03:1 apart -- without the edge there is
   no panel, only a smudge). */
.portal-hero-panel {
  background: #f0f5f1;
  border: 1px solid #d3e1d7;
  border-radius: 1rem;
  padding: 1.75rem 1.5rem;
}
@media (min-width: 640px) { .portal-hero-panel { padding: 2.5rem 2.25rem; } }

/* The trust facts as a card grid rather than a definition list with hairlines:
   two columns from 768px, one below. Each fact is a white card on the page
   ground; the old row rules are switched off inside the grid. :last-child on
   the support row still works -- an unset PORTAL_SUPPORT_EMAIL simply leaves
   an odd card count, which a grid handles and a list of rules did not. */
.portal-facts { display: grid; gap: 0.75rem; }
@media (min-width: 768px) { .portal-facts { grid-template-columns: 1fr 1fr; gap: 1rem; } }
.portal-facts > div {
  border: 1px solid var(--portal-hairline);
  border-top: 1px solid var(--portal-hairline);
  border-radius: 0.75rem;
  background: var(--portal-surface);
  padding: 1.125rem 1.25rem;
}
.portal-facts > div:first-child { border-top: 1px solid var(--portal-hairline); padding-top: 1.125rem; }
.portal-facts > div:last-child { padding-bottom: 1.125rem; }

/* Section headings carry a short accent rule above them so the brand is
   SYSTEMATIC rather than applied to buttons only (k3 1.4). Decorative; no
   contrast minimum applies. The fallback colour is the accent itself, so the
   rule renders the same whether or not body.brand-portal is set. */
.portal-section > h2::before {
  content: "";
  display: block;
  width: 2.5rem;
  height: 3px;
  border-radius: 2px;
  margin-bottom: 0.75rem;
  background: var(--brand-accent, #14532d);
}

/* Eyebrows in the accent: 12px semibold uppercase, tracked. #14532d on white
   is 9.11:1, on the #f8fafc ground 8.7:1 -- both clear 4.5:1 with room. */
.portal-kicker {
  color: var(--brand-accent, #14532d);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* Money gets its own typography: tabular figures so ranges set evenly, and the
   range NEVER breaks in the middle -- at 390px "an estimated $50–" / "$175 a
   month" was the one genuinely broken thing in the mobile set. The unit drops
   to its own line under 640px so the figure alone has the width it needs. */
.portal-figure {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  font-size: 2.25rem;
  line-height: 1.1;
}
.portal-figure-unit { display: block; font-size: 1rem; font-weight: 500; color: var(--portal-muted); white-space: normal; }
@media (min-width: 640px) {
  .portal-figure { font-size: 3rem; }
  .portal-figure-unit { display: inline; font-size: 1.25rem; }
}

/* The three arrivals under the hero (operator ruling 20260910): one card per
   kind of visitor, three across from 768px, stacked below. Same surface and
   edge as the trust-fact cards so the page has one card vocabulary. */
.portal-entries { display: grid; gap: 0.75rem; margin-top: 2rem; }
@media (min-width: 768px) { .portal-entries { grid-template-columns: 1fr 1fr 1fr; gap: 1rem; } }
.portal-entry {
  border: 1px solid var(--portal-hairline);
  border-radius: 0.75rem;
  background: var(--portal-surface);
  padding: 1.125rem 1.25rem;
}

/* The sign-in and check-your-email pages as a centred card: a lone input in
   the page ground read as unfinished, and on a phone the page was mostly the
   gap between the note and the footer. */
.portal-auth-card {
  max-width: 28rem;
  margin: 0 auto;
  background: var(--portal-surface);
  border: 1px solid var(--portal-hairline);
  border-radius: 1rem;
  padding: 1.75rem 1.5rem;
}
@media (min-width: 640px) { .portal-auth-card { margin-top: 2rem; padding: 2.25rem 2rem; } }

/* A two-option choice (the country at account creation) as radio cards: both
   answers visible at once, a 44px target each, the chosen one outlined in the
   brand green. A native radio stays in the card so keyboard and screen-reader
   behaviour is the browser's, not ours. */
.portal-choice-grid { display: grid; gap: 0.75rem; }
@media (min-width: 480px) { .portal-choice-grid { grid-template-columns: 1fr 1fr; } }
.portal-choice {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-height: 44px;
  padding: 0.75rem 1rem;
  border: 1px solid var(--portal-boundary);
  border-radius: 0.75rem;
  background: var(--portal-surface);
  font-size: 1rem;
  color: var(--portal-ink);
  cursor: pointer;
}
.portal-choice input[type="radio"] { width: 1.25rem; height: 1.25rem; margin: 0; accent-color: #14532d; flex: none; }
.portal-choice:has(input:checked) { border-color: #14532d; box-shadow: inset 0 0 0 1px #14532d; background: #f0f5f1; }
.portal-choice:has(input:focus-visible) { outline: 2px solid var(--portal-ink); outline-offset: 2px; }

/* --------------------------------------------------------------------------
 * (d) NSP brand — BEHIND THE LOGIN WALL ONLY (nsp#B213).
 *
 * Scope is the whole mechanism: every rule here hangs off `body.brand-portal`,
 * and that class is set by base.html only when PortalTemplates says the
 * template is a signed-in surface (BRANDED_TEMPLATES, app/main.py). Pre-auth
 * pages never carry the class, so nothing here can leak onto them.
 *
 * BRAND: Waypoint (operator ruling 20260909). The class names are deliberately
 * NEUTRAL (brand-portal, .portal-wordmark, --brand-accent) rather than carrying the
 * brand in the selector: the previous set was named for NSP, so a rebrand meant
 * a rename sweep across CSS and templates. Now it is a value change.
 *
 * No image asset exists in-repo yet, deliberately: the identity is a TEXT
 * wordmark plus a palette held in CSS variables. A real logo later is a swap
 * inside .portal-wordmark-badge plus new values for the two variables — zero
 * template rewrites. Colours chosen for >= 4.5:1 contrast on white.
 * -------------------------------------------------------------------------- */

body.brand-portal {
  --brand-accent: #14532d;        /* deep green — the Waypoint accent */
  --brand-accent-strong: #0f3d21; /* hover/active depth of the same hue */
}

/* The chrome accent: a slim brand keyline over the header. Deliberately the
   only page-level flourish — "tasteful", not a reskin. */
body.brand-portal > header { border-top: 3px solid var(--brand-accent); }

/* The text wordmark: [NSP] badge + the portal's own name. */
.portal-wordmark { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--portal-ink); }
/* A 28px SQUARE with no padding of its own. The mark carries its own optical
   margin inside its 64-unit viewBox (10.5% left, 19.9% top), so padding here
   would show as a second, uneven margin.

   The font-weight / font-size / letter-spacing / padding this rule used to carry
   have been dead since the badge stopped holding the letters "NSP" -- there is no
   text in it any more, only the inline mark, which takes its colour from
   `color` below.

   White on #14532d is 9.11:1 (AAA). No layout shift: base.html's header row is
   py-4 around its tallest child, and that child is the desktop nav's px-2 py-2
   text-sm links at 36px (44px on mobile, via the details summary's min-h-[44px]).
   28px is shorter than both. */
.portal-wordmark-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 0.375rem;
  background: var(--brand-accent, #14532d);
  color: var(--portal-surface);
}
.portal-wordmark-name { color: var(--portal-ink); }

/* The badge holds an inline SVG mark rather than a letter, so it needs no
   font metrics and the glyph inherits currentColor (white on the accent).
   Swapping in a different mark is one path element. */
.portal-wordmark-badge > svg { display: block; }

/* Consistent chrome: the primary action colour follows the accent everywhere
   behind the wall. These re-point the two utilities the signed-in templates use
   for primary buttons/hovers, INSIDE the brand scope only — templates keep
   their neutral classes, so un-branding (or re-branding) is a CSS change. */
body.brand-portal .bg-slate-900 { background-color: var(--brand-accent); }
body.brand-portal .hover\:bg-slate-700:hover { background-color: var(--brand-accent-strong); }
/* hover:bg-slate-800 is what _macros.html submit_button actually uses, and it
   had NO brand override -- so the single most important button in the product
   ("Set up payments", via _status_card.html inside the branded dashboard)
   hovered from brand green to Tailwind slate grey. Found 20260910.
   White on #14532d is 9.11:1 and on #0f3d21 is 12.28:1, so both states pass. */
body.brand-portal .hover\:bg-slate-800:hover { background-color: var(--brand-accent-strong); }

/* The focus ring rests at the accent, not at the hover depth. brand-basis §9.1
   settles ONE green resting everywhere (#14532d) and reserves #0f3d21 for hover,
   active and pressed; focus is none of those three. #14532d measures 9.11:1 on a
   white card, 8.71:1 on the page ground, 8.32:1 on a slate-100 chip, 7.39:1 on a
   hairline edge and 7.32:1 on the amber test-mode banner -- all far past the 3:1
   that 1.4.11 and 2.4.11 ask of a focus indicator.

   One adjacency is worth stating rather than assuming: around a PRIMARY button
   the ring is #14532d on a #14532d button, which is 1:1. It reads only because
   every branded control carries focus-visible:outline-offset-2, so 2px of the
   container colour separates them. A primary button moved to outline-offset-0
   would lose its focus ring entirely. */
body.brand-portal :focus-visible { outline-color: var(--brand-accent); }

/* Link text, underlined -- accent place 3 of the five in brand-basis §3, and it
   was empty. Neither `underline` nor `hover:text-slate-900` sets a resting
   colour, so every link behind the wall inherited the body's slate-800 (#1e293b,
   1.41:1 against the surrounding #334155 prose -- indistinguishable) and hovered
   to #0f172a, a 1.22:1 state change nobody can perceive.

   The hook is the underline utility on the two elements that are actually links:
   any anchor, and a <button> inside <main> styled as one ("Refresh status",
   _status_card.html, which sits in the same row as three real links). The <main>
   qualifier is what keeps "Sign out" out of it -- that button is also underlined
   and lives in the header <nav>, where green would be a sixth accent placement.

   #14532d is 9.11:1 on a white card and 8.71:1 on the page ground; the hover
   depth #0f3d21 is 12.28:1 and 11.74:1. Against the surrounding prose the link
   is only 1.14:1, which 1.4.1 permits because colour is not the sole
   distinguisher: the selector REQUIRES the underline class, so a link that ever
   loses its underline loses the green with it. */
body.brand-portal a.underline,
body.brand-portal main button.underline { color: var(--brand-accent); }

body.brand-portal a.underline:hover,
body.brand-portal a.underline:active,
body.brand-portal main button.underline:hover,
body.brand-portal main button.underline:active { color: var(--brand-accent-strong); }

/* --------------------------------------------------------------------------
 * (c) Baseline: motion, focus visibility, print.
 * -------------------------------------------------------------------------- */

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

/* A visible focus indicator for anything that never got an explicit one. Removing
   the default outline without replacing it is the single commonest keyboard-a11y
   regression, so the fallback is stated here rather than assumed. */
:focus-visible { outline: 2px solid var(--portal-ink); outline-offset: 2px; }

/* A long opaque identifier (a Stripe Checkout Session id on the membership
   card) has no natural break point; without this it overflowed its card.
   Wrap anywhere rather than clip or scroll: the value is for the record and
   is copied, not read. */
.portal-ref { overflow-wrap: anywhere; word-break: break-all; }

/* Sign-in honeypot (nsp#B316). Moved off-screen rather than display:none,
   because some form bots skip fields that are display:none. aria-hidden and
   tabindex=-1 on the markup keep it away from keyboard and screen-reader users. */
.portal-hp {
  position: absolute;
  left: -10000px;
  top: auto;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
