/* Core, brand-neutral utility classes. CSP-clean (slice D) replacements for the
   former inline style="…" attributes on the pre-theme public / portal / admin
   bodies. Loaded on every page from app/views/layout.php, after Bootstrap and
   before the optional client brand stylesheet (so a theme can override). Kept
   intentionally minimal — the admin surface is dressed by the client theme layer;
   these classes only preserve the pre-theme structure without inline styles. */

/* Off-screen spam honeypot. Paired with aria-hidden="true" on the wrapper so
   assistive tech skips it too (a real screen-reader user must not fill it). */
.honeypot { position: absolute; left: -9999px; }

/* Bordered admin panels / cards (formerly inline border/padding/margin). */
.admin-box   { border: 1px solid #999; padding: 8px;  margin: 8px 0; }
.admin-panel { border: 1px solid #999; padding: 12px; max-width: 640px; }
.tpl-box     { border: 1px solid #ddd; padding: 1rem; margin: 1rem 0; }

/* Small indent for the read-only participant list under an admin member card. */
.indent-1 { margin-left: 1em; }

/* Multiline file-value revert hints on /admin/settings: honour the \n line
   breaks in a stored multiline value (address blocks) without inline styles. */
/* SP-7e: ...and let that value BREAK. A settings file value is arbitrary text — an
   unbroken URL (contact.directions_url is ~100 chars with no spaces) has no break
   opportunity at all, so the hint renders one 435px-wide line and drags /admin/settings
   sideways on a phone. `pre-line` collapses runs of whitespace and honours newlines; it
   creates no NEW break points, so it cannot solve this on its own. Scoped to this class
   on purpose: `overflow-wrap` is inherited, and applying it site-wide would change text
   rendering everywhere for one element's benefit. */
.file-value-hint { white-space: pre-line; overflow-wrap: anywhere; }

/* SP-7e (D4): the HTML size= and cols= attributes set an INTRINSIC width - size="80"
   renders ~630px - which no table wrapper can fix and which drags the whole page
   sideways on a phone. Bootstrap's .form-control already sets width:100%, but these
   admin inputs are bare elements that never received the class. Capping at 100% leaves
   every element that already fits untouched.

   `label` is in the list ON PURPOSE and the rule is inert without it. Bootstrap's
   reboot sets `label { display: inline-block }`, and every one of these admin controls
   is wrapped in its own <label>. A shrink-to-fit inline-block sizes to its content, so
   the label grows to the input's intrinsic 630px, and the input's `max-width: 100%`
   then resolves against that same 630px - capping it at exactly the width it already
   had. Capping the label first gives the input a containing block that fits. */
input, textarea, select, label { max-width: 100%; }

/* SP-7e (D3b): opt-in card layout for tables too wide to scroll comfortably. The D3
   wrapper (.table-responsive) already stops the PAGE breaking, but /admin/offerings/roster
   is a 967px, 12-column table that still scrolls 616px sideways inside its own box on a
   phone. Below the md breakpoint each row becomes a labelled card instead, so there is no
   sideways scrolling at all. 767.98px is Bootstrap 5.3.3's md breakpoint, verified against
   the vendored public/assets/vendor/bootstrap/bootstrap.min.css. Above it the whole block
   is inert, so desktop admin is unchanged and the risk stays on small viewports.

   Each <td> carries data-label="<column>"; the label is rendered with CSS generated
   content, which is CSP-clean (it is CSS, not script, and lives in this stylesheet). */
@media (max-width: 767.98px) {
    .table-stack,
    .table-stack tbody,
    .table-stack tr,
    .table-stack td { display: block; width: auto; }
    .table-stack thead { display: none; }
    /* These admin tables are drawn by the presentational border="1" attribute, which
       borders the TABLE as well as the cells. The cards below supply their own border,
       so the table's own has to go or every card sits inside a second, outer box. */
    .table-stack { border: 0; }
    .table-stack tr {
        border: 1px solid #999;
        border-radius: 4px;
        margin-bottom: .75rem;
        padding: .5rem;
    }
    /* `overflow-wrap` is folded in here rather than kept as a second .table-stack td rule.
       A guardian email or street address is one unbroken token with no break opportunity of
       its own, and a card's content box is only ~349px wide. Measured at 390x844 on the
       roster's Email cell, same cell, rule toggled inline, with this exact 67-character
       string (deliberately containing NO hyphen — a hyphen is itself a break opportunity,
       so a hyphenated string of the same length is not the same test):

           jonathanmacdonaldwitherspoonxxx@northumberlandcountyschools.example

       with the rule:    cell scrollWidth 333px, fits the 349px card, wrapper side-scroll 0px
       without it:       cell scrollWidth 544px, overflows the card by 195px, wrapper 202px
       same string with one hyphen at offset 20, without the rule: 374px / 25px / 32px

       So the rule bounds an input the view cannot control; it moves ZERO pixels on the
       seeded fixture, whose addresses are short. The page itself never breaks in any of
       these states (the .table-responsive wrapper absorbs it) — what is at stake is the
       sideways scrolling inside the card that D3b exists to remove. Scoped to stacked
       cells: `overflow-wrap` inherits, and a site-wide rule would change text rendering
       everywhere for these cells' benefit. */
    .table-stack td { border: none; padding: .25rem 0; overflow-wrap: anywhere; }
    .table-stack td::before {
        content: attr(data-label) ": ";
        font-weight: 600;
    }
    /* NO `td:empty { display: none }` (it was in the plan, and it is deliberately not here).
       The reason is an INVARIANT of the markup, not a property of any data set:
       admin/roster.php:91 renders the Emergency cell as two echoes with a literal space
       between them, "<td>{emerg_name} {emerg_phone}</td>", so that cell always holds at
       least that space — and :empty matches only a cell with NO child nodes at all
       (verified in Chromium: a <td> holding a single space does not match, a <td> holding
       nothing does). An Emergency cell can therefore never be hidden by this rule however
       blank it looks, while its siblings can; the result would be exactly one bare
       "Emergency: " label per row, which is the defect the plan added the rule to prevent.
       Counts, measured on the dev fixture and true only of it: of 252 body cells across 21
       rows, 43 render blank and all 43 are :empty (Guardian 21, Phone 21, Medical ID 1) —
       Guardian is blank only because the dev seed never sets users.full_name, which
       production requires at the enrolment gate (guardian_is_complete()).
       Showing every label always is also the safer reading: on a roster of children a
       missing "Medical ID" line is indistinguishable from "no medical id recorded", and
       these are the fields staff check in an emergency. */
}

/* SP-7b: the PIPEDA data-export page is produced to be printed to PDF and sent to the
   requester. Print rules only — the screen rendering is Bootstrap's. */
.data-export h2 { margin-top: 1.5rem; }
.data-export .export-meta { margin-bottom: 1.5rem; }
/* Hides ONLY .no-print: app.css is linked on every page and layout.php always renders the
   header/footer partials, so hiding header/footer/nav here stripped the studio's name and
   contact details out of EVERY printed page — including the PDF mailed to a requester. */
@media print {
    /* Menu links + the hamburger are noise on a document that gets mailed to a requester, but
       the brand sits INSIDE .navbar (partials/header.php), so hide the links — not the nav. */
    .no-print, .navbar-nav, .navbar-toggler { display: none; }
    .data-export { font-size: 11pt; }
    .data-export h2 { page-break-after: avoid; }
    .data-export table { page-break-inside: avoid; }
    /* SP-7e (D3): undo the scroll container ON PAPER. `.table-responsive` is
       `overflow-x: auto`, which is right on a screen and wrong in print, twice over —
       and Bootstrap 5.3's only @media print block is the .d-print-* utilities, so
       nothing else resets it:
         1. `overflow` other than `visible` establishes a block formatting context, so a
            table's margin-bottom stops collapsing into the following <h2>. Measured on
            /admin/members/export: 6609px unwrapped -> 6688px wrapped, +79px of dead
            whitespace that shifts every page break after it.
         2. Paper has no scrollbar, so overflow is cut PERMANENTLY — and worse, Chrome's
            print shrink-to-fit triggers on DOCUMENT overflow, which is exactly what the
            wrapper exists to eliminate. Measured, print-to-PDF, Letter: a 12-column
            table renders 317 glyphs at scale 0.50 unwrapped (shrink engaged) and 221 at
            0.75 wrapped — 96 glyphs, 30%, silently gone. A 2-column .table-sm holding
            one 87-character unbreakable value loses 22.
       This document is the PIPEDA data export that is printed and mailed to a privacy
       requester (SP-7b), so "some characters are missing" is a legal defect, not a
       cosmetic one — and it is silent and data-dependent. Applied to every
       .table-responsive, not just .data-export's: any admin table can be printed, and
       a scroll container is meaningless on paper everywhere. */
    .table-responsive { overflow: visible; }
}

/* SP-7h (D5): Bootstrap 5.3.3's `.btn-outline-warning` RESTING state is the worst
   contrast ratio anywhere in this application. The vendored build sets
   `--bs-btn-color: #ffc107` and `--bs-btn-border-color: #ffc107`, and #ffc107 on the
   #ffffff page background measures 1.63 (measured) against WCAG 1.4.3's 4.5 for text.
   The BORDER additionally fails WCAG 1.4.11 non-text contrast at 1.63 against the 3.0
   threshold — a criterion no Lighthouse audit tests, so this half of the defect is
   invisible to the usual tooling.

   #664d03 is Bootstrap 5.3.3's OWN `--bs-warning-text-emphasis`, read out of the
   vendored build (`public/assets/vendor/bootstrap/bootstrap.min.css`) — not an invented
   colour, and brand-neutral (a Bootstrap token, not an AnJil's colour). It measures
   7.99 on white (measured), clearing both 4.5 and 3.0. ⚠ Do NOT "correct" this to
   #997404, the shade most style guides name as the accessible amber and the value some
   Bootstrap 5.3 builds carry: it measures 4.33 and FAILS. Read the vendored file; do
   not trust the reputation of a hex. ⚠ And grep the token NAME, not the hex: #997404 IS
   present in this vendored build, as --bs-warning-border-subtle, so grepping the hex
   alone hits and reads like a contradiction of this comment. (It also carries a second value,
   `--bs-warning-text-emphasis: #ffda6a` — that is the DARK-mode variant. This app ships
   the light-mode default, so #664d03 is the applicable one.)

   HOVER/ACTIVE ARE DELIBERATELY UNTOUCHED. `--bs-btn-hover-color: #000` on
   `--bs-btn-hover-bg: #ffc107` already measures 12.88, so the amber survives as the
   caution signal exactly where it is legible; only the resting text and border move.
   For completeness, --bs-btn-disabled-color / --bs-btn-disabled-border-color also stay
   #ffc107 (1.63): WCAG 1.4.3 and 1.4.11 both exempt inactive controls, and no instance
   in this app is disabled today. --bs-btn-focus-shadow-rgb stays amber too (focus rings
   are a separate question, not D5's).

   TWO CUSTOM PROPERTIES, NOT A CLASS SWAP, ON PURPOSE — this override fixes BOTH live
   instances without touching either line of markup:
     1. app/views/admin/offering-sessions.php:42 — the literal `btn-outline-warning` on
        the "Cancel a session" button. It renders once per NON-CANCELLED session, so the
        count varies by offering: 7 with the SP-7e fixture at ?offering=1 (8 sessions, 1
        already cancelled) — not a fixed 7.
     2. app/views/admin/offering-sessions.php:90 — built as `'btn-outline-' . $variant`
        from `$marks['late'] => 'warning'`, the attendance "Late" button. It renders only
        at /admin/offerings/sessions?offering=N&roster=M — a query-string page STATE, not
        a route, which the route-based audit never visits. A grep-driven fix of :42 would
        have shipped with instance 2 still live.
   ...plus any future use of the variant, in every install. CORE layer: this ships to
   every studio, not just AnJil's. No !important, no new class, no specificity fight. */
.btn-outline-warning { --bs-btn-color: #664d03; --bs-btn-border-color: #664d03; }

/* SP-7h (D8): standalone back-links are a 24px tap target (WCAG 2.5.8 target-size).
   Fixes 27 sites with zero markup edits. CORE layer: ships to every studio.

   CAUSE. An inline non-replaced element's border box comes from FONT METRICS, and
   `line-height` grows the LINE box, not the inline box — so with Bootstrap's body
   line-height at 24px, exactly the threshold, the <a> still measures 21px. Spacing is
   the wrong half to fix: axe passes on EITHER size or spacing, and spacing misses by
   only 0.2px, so a margin clears it today and regresses on the next neighbour added.

   WHAT EACH DECLARATION ACTUALLY DOES — measured, not what it looks like:
   - `padding-block` IS the mechanism. Padding does enter an inline box: alone it takes
     the link 21 -> 25px and target-size to 0, with zero page reflow.
   - `inline-block` is NOT what makes it pass. It raises the floor to the line-height box
     (28px; 32.8 in /terms's .legal-body) so the margin over 24 stops depending on font
     metrics — and, the real reason to pay the +4px, it makes the target GENUINELY occupy
     the space: inline padding grows an INVISIBLE box overlapping the neighbouring lines
     of text, and an auditor could fairly say the target never got bigger.
   - `max-width: 100%` is MEASURED INERT on today's content (the longest back-link, at
     /admin/offerings/sessions?offering=1&roster=49, is 342x52 at 390px either way) and
     does NOT defend against a long unbreakable href — that overflows the page by an
     identical 1144px with it, without it, and without D8, because inline-block clamps
     the BOX, not the paint. A cheap bound only; real remedy in docs/FUTURE_UPGRADES.md.

   COST: +4px per matched PARAGRAPH — +28px on /admin/members, which has 7. A wrapped
   link's hit area also becomes ONE rectangle instead of two text runs, so the space
   after the last wrapped word turns clickable — benign, and arguably what 2.5.8 wants.

   THE SELECTOR approximates 2.5.8's in-a-sentence carve-out, and CSS offers nothing
   better: no selector can test for text nodes. `:only-child` counts ELEMENT children, so
   a lone link in prose matches too — content/pages/classes.php:98 and terms.php:23 both
   do, and both reflow from two line-fragments to one whole link, legibly and without
   overflow. /privacy escapes because each of its four `p > a` has a SIBLING ELEMENT
   (<strong> on :93 and :131, <br> plus a second anchor on :128 and :129), not because it
   is a sentence. `:not(.btn)` excludes exactly one node, admin/offering-sessions.php:13.
   NOT covered, deliberately: app/views/portal/profile.php:126 has TWO links in one <p>,
   so it is not an only-child and stays 21px — a markup fix, parked. */
p > a:only-child:not(.btn) { display: inline-block; padding-block: .125rem; max-width: 100%; }

/* SP-7f - public month calendar (/schedule). CORE, and deliberately theme-free: this
   file contains ZERO client colour variables and must not start now. The block below
   names three Bootstrap greys and nothing else - no var(--...) at all, measured - and
   inherits its text colour, so a core view can never reintroduce the accent-as-text
   contrast defect SP-7h fixed (spec D10). A client brands the calendar by overriding
   .schedule-* in its own theme stylesheet, where the three-variable rule in
   OWNER_ADMIN_GUIDE section B10k applies.
   CoreBrandNeutralityTest and CspComplianceTest do NOT scan this file. Neither is
   evidence about anything below. */
.schedule-grid { table-layout: fixed; }
.schedule-grid th { font-weight: 600; }
/* `height` on a display:table-cell is a MINIMUM, not a fixed size (CSS 2.1 section
   17.5.3), and a table cell ignores `overflow`, so this cannot clip. Measured at
   1350x940 on the seeded fixture: 5.5rem resolves to 88px and every empty cell is
   exactly 88, while the 2026-07-18 two-session cell is 139.5 - the row grew to its
   content rather than the content being cut. It is `height` and not `min-height`
   because min-height is undefined on table-cell. Its whole job is to keep EMPTY cells
   from collapsing to a thin strip. */
.schedule-cell { height: 5.5rem; vertical-align: top; }
.schedule-cell-out { background-color: #f8f9fa; }
/* The today marker is an outline AND a visually-hidden "(today)" in the markup - never
   colour alone (WCAG 1.4.1). ⚠ That pairing is the VIEW's job, not this rule's: the
   class is only ever emitted on an in-month cell, which is the same condition the text
   is under (app/views/schedule.php, $isToday). This rule cannot tell the difference, so
   an out-of-month cell that reaches .schedule-today is a 1.4.1 DEFECT, not a styling
   choice - it happened once, at ?m=2026-08 on 2026-07-31, and was fixed in the view.
   outline-offset keeps the outline inside the cell so it cannot widen the table. */
.schedule-today { outline: 2px solid #212529; outline-offset: -2px; }
.schedule-daynum { font-size: .875rem; font-weight: 600; }
.schedule-entry { font-size: .8125rem; line-height: 1.25; overflow-wrap: anywhere; }
.schedule-entry + .schedule-entry { margin-top: .25rem; }
.schedule-time { display: block; color: #495057; }
/* The agenda's twin of .schedule-entry's wrap rule, and required for the same reason:
   the name is a flex item (min-width:auto), so its floor is the longest unbroken token
   of an admin-authored offerings.name. `anywhere` not `break-word` - only `anywhere`
   shrinks a flex item's min-content size. Measured at 390x844 with a 46-char alpha token
   as the name: the page overflowed by 142px (/schedule) and 122px (?m=2026-08) without
   this rule and by 0px with it, name box 367 -> 200px. axe also cleared 5 `color-contrast`
   INCOMPLETE nodes that the off-screen overflow was producing. */
.schedule-agenda-name { overflow-wrap: anywhere; }

/* ---------------------------------------------------------------------------
   SP-7g blog (D14, D5). Brand-neutral: every colour below is a literal grey from
   the Bootstrap palette and NO client theme custom property is referenced, so this
   block cannot reintroduce the 2.86 contrast defect SP-7h fixed.

   The only guards on this file are a client-token grep and the axe re-sweep --
   neither CoreBrandNeutralityTest nor CspComplianceTest reads public/assets/, so
   every line below is VACUOUSLY green under both. A recorded gap, not a new one.

   The grep is for the CLIENT NAME as a bare token and it counts LINES, not
   variables, so a comment that merely NAMES the token fails it exactly as a real
   `var(--<client>-…)` reference would. This paragraph is written the long way round
   for that reason -- measured 2026-07-31, when the first draft of it scored 2.
   --------------------------------------------------------------------------- */
.blog-index { margin-top: 1.5rem; }
.blog-card + .blog-card { margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #dee2e6; }
.blog-card-title { margin-bottom: .25rem; }
.blog-card-date, .blog-post-date { font-size: .875rem; color: #495057; margin-bottom: .5rem; }
.blog-card-excerpt { margin-bottom: 0; }
/* The twin of .schedule-agenda-name's rule, and required for the same measured
   reason: a post title is admin-authored free text, so its floor is its longest
   unbroken token. `anywhere` not `break-word` -- only `anywhere` shrinks a
   min-content size. Covers the title on both the card and the post page, and the
   post BODY, where an admin can paste a long URL as link text. */
.blog-card-title, .blog-post h1, .blog-post-body { overflow-wrap: anywhere; }
.blog-post-body { margin-top: 1rem; }
/* A pasted image must never widen the page. The body is trusted raw HTML (D5), so
   the admin controls the markup but not the phone it lands on -- this is the only
   fence between a 2000px-wide upload and a horizontal scrollbar on every reader's
   device. SP-7g D15 makes in-body images a shipped feature, so this is load-bearing. */
.blog-post-body img { max-width: 100%; height: auto; }
/* Bootstrap tables inside admin-authored body HTML would side-scroll the page. */
.blog-post-body table { display: block; max-width: 100%; overflow-x: auto; }
.blog-post-back { margin-top: 2rem; }
.blog-empty { margin-top: 1.5rem; }

/* SP-7g D15 — the admin image picker. `wrap` is the load-bearing declaration: at 390px a
   120px thumbnail beside a 60-character snippet input would not fit on one line, and a
   flex row that cannot wrap does not shrink, it overflows. The snippet input is the
   no-JS contract, so it has to stay selectable at every width. */
.blog-image-row { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;
                  margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #dee2e6; }
.blog-image-row img { max-width: 100%; height: auto; }
.blog-snippet { max-width: 100%; font-family: monospace; font-size: .8125rem; }

/* ---------------------------------------------------------------------------
   TEMPLATE-DEFECTS Task 5 (2026-08-01) — THE KEYBOARD FOCUS INDICATOR, SITE-WIDE.

   THE DEFECT (SP-7h, measured): Bootstrap's focus ring is
   `box-shadow: 0 0 0 .25rem rgba(13,110,253,.25)` — TRANSLUCENT, so it composites
   down onto whatever is behind it. Over #264653 it becomes rgb(32,80,126) = 1.20:1.
   That is how a keyboard user knows where they are, so it is a real usability
   defect as well as a WCAG one (definite 2.4.11/2.4.13, arguable 1.4.11 — the ring
   is author-supplied, so the user-agent exemption does not rescue it).

   ⚠ TWO CORRECTIONS TO THE OBVIOUS FIX, BOTH MEASURED. Read them before editing.

   1. SETTING --bs-focus-ring-color DOES NOTHING IN THIS BUILD. That token is read by
      the `.focus-ring` UTILITY CLASS and by nothing else, and `.focus-ring` appears
      nowhere in app/, theme/, content/ or either stylesheet. The rings that actually
      render are SIX HARDCODED rgba(13,110,253,.25) box-shadows on the selectors
      overridden below. Overriding the token alone leaves every form control at 1.20.

   2. A SINGLE OPAQUE COLOUR CANNOT PASS ON BOTH DARK AND LIGHT GROUNDS. The brand
      gold #e9c46a measures 6.03 on #264653 and 8.79 on #1b2a35 — and 1.67 on white,
      1.59 on #f8f9fa, 1.45 on #f7ebf7. Light grounds are where nearly every
      focusable element on this site lives, so that fix would have swapped one
      partial failure for another. (Alpha tuning does not rescue it either:
      #dbaedb @.45 = 2.27, @.60 = 2.92, #e9c46a @.50 = 2.67. All below 3.0.)

   THE RING IS THEREFORE TWO-TONE AND OPAQUE: a dark inner ring against the control
   and a light outer ring against the page. One of the two always clears 3.0 against
   the ground, and the two clear 21.0 against each other, so the indicator is
   perceivable on ANY surface. Core ships neutral #000/#fff (rule 1 — no client
   colour in core); a theme RE-COLOURS the two tokens and must not re-shape the ring,
   which is what keeps one focus idiom across both layers.

   `outline: 2px solid transparent` is NOT decoration: in Windows forced-colors mode
   box-shadows are dropped, and a transparent outline is repainted by the OS. Removing
   it silently deletes the focus indicator for exactly the users who need it most.

   Arithmetic and the per-ground table are asserted in tests/FocusRingTest.php, which
   COMPUTES the ratios rather than restating them, so editing a hex here fails there.
   --------------------------------------------------------------------------- */
:root {
    --sc-focus-ring-inner: #000;
    --sc-focus-ring-outer: #fff;
    --sc-focus-ring-shadow: 0 0 0 2px var(--sc-focus-ring-inner), 0 0 0 5px var(--sc-focus-ring-outer);
}

:focus-visible {
    outline: 2px solid transparent;
    outline-offset: 2px;
    box-shadow: var(--sc-focus-ring-shadow);
}

/* The six vendored rings are HARDCODED, so each is named. .form-control:focus and
   friends are (0,2,0) specificity — a bare :focus-visible is (0,1,0) and loses to
   them even though this file loads after Bootstrap. `:focus` and not `:focus-visible`
   on the form controls, deliberately: that is the selector Bootstrap itself uses, and
   a text input matches both. */
.form-control:focus,
.form-select:focus,
.form-check-input:focus,
.nav-link:focus-visible,
.btn:focus-visible,
.navbar-toggler:focus,
.btn-close:focus {
    outline: 2px solid transparent;
    outline-offset: 2px;
    box-shadow: var(--sc-focus-ring-shadow);
}
.form-range:focus::-webkit-slider-thumb { box-shadow: var(--sc-focus-ring-shadow); }
.form-range:focus::-moz-range-thumb     { box-shadow: var(--sc-focus-ring-shadow); }

/* ---------------------------------------------------------------------------
   THE CORE CHROME'S OWN CONTRAST (2026-08-02) — the layer an UNTHEMED install
   renders. CORE, brand-neutral: every value below is a token read out of the
   vendored Bootstrap build, not an invented colour and not a client colour.

   ⚠ WHY THIS WAS NEVER CAUGHT. SP-7h's `color-contrast` 97 -> 0 across 39-44
   routes was measured against the AnJil's-THEMED install, and that theme
   REPLACES app/views/partials/{header,footer}.php. The core chrome was in none
   of those readings. Re-measured 2026-08-02 with CLIENT_ROOT pointed at a bare
   client layer (no theme/views, no brand.stylesheet), axe-core 4.12.1, 86
   route x width readings at 390 and 1350, anonymous and signed-in:
   ZERO of the 86 were clean. The SAME pages on the themed install: 0 violations.

     #0d6efd on #f8f9fa = 4.26 (needs 4.5) — the footer's links, EVERY page. One
                          link on a bare install; FOUR per page once the install
                          has legal pages, which scripts/init-legal.php gives
                          every install.
     #6c757d on #f8f9fa = 4.44 (needs 4.5) — the navbar's .btn-outline-secondary
                          "Log out", every signed-in page at >= md. (At 390 the
                          navbar collapses behind the toggler, so this one is
                          invisible to a mobile-only sweep.)

   ⚠ THE MARGIN IS THE REAL DEFECT, NOT THE TWO FAILURES. Of 1252 rendered text
   nodes, #0d6efd on pure #ffffff measures EXACTLY 4.50 — it clears AA by 0.00,
   on 74 instances (every ordinary body link). The footer failure is not a
   one-off; it is that zero margin cashing in one shade of grey later. So the
   remedy is chosen against EVERY light ground the vendored build ships, not
   against the two grounds where the failure happened to be observed:

     ground                       #0d6efd (today)   #0a58ca (this fix)
     #ffffff body                      4.50               6.44
     #f8f9fa navbar + footer           4.27               6.11
     #cfe2ff primary-bg-subtle         3.80               5.43
     #d1e7dd success-bg-subtle         3.47               4.96
     #f8d7da danger-bg-subtle          3.37  <- worst     4.82  <- worst
     #fff3cd warning-bg-subtle         4.06               5.81
     #cff4fc info-bg-subtle            3.86               5.52
   #0d6efd fails four of these outright. #0a58ca clears all of them.

   ⚠ THE MECHANISM, RE-DERIVED RATHER THAN ASSUMED. The vendored rule is
   `a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1))}` — it reads
   the *-rgb* variant. Setting `--bs-link-color` alone satisfies a source test
   and changes NOTHING on screen, which is exactly what `--bs-focus-ring-color`
   did in SP-7h above. CoreChromeContrastTest re-derives this from the shipped
   vendor file and fails if a future Bootstrap moves it.

   PROVENANCE, and grep the token NAME not the hex:
     #0a58ca IS the vendored `--bs-link-hover-color` (also .btn-primary's
             active-bg). Bootstrap's own next step down the same blue.
     #084298 is present as `--bs-primary-border-subtle`. It is used here for its
             VALUE, not its role — hover must keep moving in the right direction
             once rest has darkened. ⚠ The role-correct candidate
             `--bs-primary-text-emphasis` (#052c65) was MEASURED AND REJECTED:
             at 13.51 on white it is fine for contrast but measures 1.14 against
             body text #212529, so a hovered link stops reading as a link at all.
             Rest->hover here is 6.44 -> 9.36 on white, the same ~1.45x step
             Bootstrap's own 4.50 -> 6.44 makes.
     #5c636a IS the vendored `--bs-btn-hover-bg` for .btn-secondary. 6.09 on
             white and 5.78 on the .bg-light navbar, against 4.5 for the TEXT
             (1.4.3) and 3.0 for the BORDER (1.4.11 — a criterion no Lighthouse
             audit tests, so that half of the defect is invisible to the usual
             tooling).

   TWO CUSTOM PROPERTIES ON .btn-outline-secondary AND NOT A CLASS SWAP, exactly
   as .btn-outline-warning above: it fixes every instance in every install
   without touching a line of markup. HOVER/ACTIVE ARE DELIBERATELY UNTOUCHED —
   `--bs-btn-hover-color:#fff` on `--bs-btn-hover-bg:#6c757d` already measures
   4.69, so the resting text and border are all that move.

   ⚠ NO BLAST RADIUS ON A THEMED INSTALL, and that was measured, not reasoned:
   theme.css:89 declares a bare `a{color:var(--anjils-accent-on-light)}`, which
   is a later rule at equal specificity, so AnJil's keeps its own link colour;
   and AnJil's replaces both chrome partials anyway. Verified by re-running axe
   against the themed install after this change.

   NOT CHANGED HERE, and recorded so the next reader does not think it was
   missed: `.btn-primary` (white on #0d6efd) and `<code>` (#d63384 on white)
   BOTH measure exactly 4.50. They PASS 1.4.3 today, with zero margin. Moving
   them repaints the default theme's principal button, which is a design
   decision, not a conformance one — it belongs to the default-theme slice.
   --------------------------------------------------------------------------- */
:root {
    --bs-link-color-rgb: 10, 88, 202;       /* #0a58ca */
    --bs-link-hover-color-rgb: 8, 66, 152;  /* #084298 */
}

.btn-outline-secondary { --bs-btn-color: #5c636a; --bs-btn-border-color: #5c636a; }
