/* 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. */

/* ===========================================================================
   B1 — THE `--sc-*` TOKEN CONTRACT (2026-08-05). EIGHT VALUES RE-SKIN THE PRODUCT.

   A client theme used to be a copy-and-rename of 415 hand-written lines plus a
   six-step manual colour derivation. This block is what replaces that: core owns
   STRUCTURE, the client owns EIGHT TOKENS and its imagery. Set them in the client
   layer's own stylesheet (`brand.stylesheet`), which loads AFTER this file.

   ⚠⚠ THE NAMES ARE THE RULE, NOT A LABEL. `-on-X` means "this colour is drawn AS
   TEXT, on an X ground". A single brand colour CANNOT be text-safe on both a light
   and a dark ground — that is why there is no `--sc-accent` to set once and be done.
   IN `background:` POSITION THE NAME DOES NOT APPLY: painting `--sc-brand-on-light`
   onto dark furniture is CORRECT when what gets graded is `#fff` on the painted
   background — which is exactly what `.btn-primary` below does. The dangerous
   converse is painting an `-on-dark` shade as a panel under white text; that
   measures 1.89 in the shipped AnJil's theme and is a comprehensive failure.

   ⚠ `--sc-brand` IS DECORATION ONLY — gradients, accent bars, section washes,
   rules. NEVER text, and never a ground that text is drawn on. It is the one token
   `scripts/dev/check-theme.php` (B2) does not grade, so painting text with it puts
   an UNGRADED colour in front of a reader. Core itself paints nothing with it; it is
   declared so a client has a sanctioned name for the decorative case.

   ⚠ THIS IS A CONVENIENCE LAYER, NOT A WALL. All 446 `--bs-*` variables stay
   available to a client that needs finer control. The tokens exist to make the
   COMMON re-skin cheap and the CONFORMANCE rule unmissable, not to hide Bootstrap.

   ⚠⚠ WHY THE MAPPINGS BELOW ARE CORE'S OWN RULES AND NOT `--bs-*` ASSIGNMENTS.
   Measured in Chromium against the vendored 5.3.3 build, 2026-08-05, because the
   obvious version of this slice was wrong in two ways that BOTH pass a source test:

     1. `--bs-primary` IS READ ZERO TIMES by the vendored build. It is declared once
        and consumed never — `.btn-primary`, `.text-primary` and `.bg-primary` all
        stayed rgb(13,110,253) with `--bs-primary` re-pointed. The variables that DO
        render are `--bs-primary-rgb` and friends, and `.text-primary`/`.bg-primary`/
        `.border-primary`/`.link-primary` are used ZERO times across app/views/ — so
        that hook drives nothing core ships either. `.btn-primary` (50 instances) is
        reachable only through its own `--bs-btn-*` locals. Same trap as
        `--bs-focus-ring-color` and `--bs-link-color` below: grep the vendored file
        for `var(<name>)` before mapping onto a `--bs-*` name.

     2. BOOTSTRAP'S COLOUR HOOKS THAT RENDER ARE RGB TRIPLES, AND THESE TOKENS ARE
        HEX. `a` reads `rgba(var(--bs-link-color-rgb), …)`, which needs three bare
        numbers. `--bs-link-color-rgb: var(--sc-brand-on-light)` yields
        `rgba(#0a58ca,1)` — invalid at computed-value time, so `color` is DROPPED and
        every link on every page inherits body text: measured rgb(13,110,253) ->
        rgb(33,37,41). BLACK LINKS, site-wide, from a change that reads correct.
        Relative-colour syntax `rgb(from var(--x) r g b)` was tried as a rescue and
        measures the SAME black. `.bg-light` fails identically (`--bs-light-rgb`, a
        hex there measured a TRANSPARENT navbar).
        ⇒ Denis's decision 2026-08-05: keep the tokens HEX — a themer must never be
        asked to write `10, 88, 202` — and let core consume them in its own rules.
        `ThemeTokenContractTest` guards both traps in both directions.
   =========================================================================== */
:root {
    /* DECORATION ONLY. Never text, never a ground under text. Ungraded by contract. */
    --sc-brand: #0d6efd;

    /* Text on a light ground, or a ground with #fff text on it (see the boundary above). */
    --sc-brand-on-light: #0a58ca;
    --sc-brand-on-light-hover: #084298;

    /* Text / UI in the site chrome. Core's chrome ground is LIGHT by default (below),
       so the default here is the same shade the footer links already rendered. */
    --sc-brand-on-dark: #0a58ca;

    /* ⚠ THE CHROME GROUND, AND ITS DEFAULT IS NOT THE SPEC'S #212529. Core's navbar
       and footer render `#f8f9fa` — measured on the unbranded demo box 2026-08-05.
       Shipping a dark default would repaint the unbranded product near-black and
       would undo the core-chrome contrast fix at the foot of this file. The token is
       still the DARK-ground token of the contract: a client sets it dark and the
       chrome follows, with `--sc-brand-on-dark` as its graded partner. */
    --sc-ink: #f8f9fa;

    /* The page ground. */
    --sc-surface: #ffffff;

    /* Type. Defaults REFERENCE the vendored stack rather than copying it, so a
       Bootstrap upgrade cannot leave a 12-family list silently out of date here. */
    --sc-font-body: var(--bs-font-sans-serif);
    --sc-font-heading: var(--bs-font-sans-serif);

    /* --- the mappings that are plain `--bs-*` assignments, because these two hooks
           take a VALUE rather than a triple and the vendored build really reads them --- */
    --bs-body-bg: var(--sc-surface);
    --bs-body-font-family: var(--sc-font-body);
}

/* Headings. Bootstrap 5.3.3 has NO heading font-family variable — it ships
   `--bs-heading-color` and nothing else, and its `h1..h6` rule sets no family at all —
   so this is a core rule rather than a `--bs-*` mapping. Default is the body stack,
   which is what headings already inherited: an install that sets nothing sees no change. */
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 { font-family: var(--sc-font-heading); }

/* Links. This is now the ONE rule colouring a link in core — the previous
   `--bs-link-color-rgb` override was REMOVED rather than left beside it, because two
   mechanisms on one surface is how `CoreChromeContrastTest` ends up grading a value the
   cascade no longer uses. Bare `a` is (0,0,1) and this file loads after Bootstrap, so it
   wins on source order; `.btn` and `.nav-link` are (0,1,0) and are correctly untouched.
   ⚠ A THEMED INSTALL IS UNMOVED AND THAT IS MEASURED, NOT REASONED: anjils/theme.css:89
   declares its own bare `a { color: var(--anjils-accent-on-light) }`, a later rule at
   equal specificity, so AnJil's keeps its own link colour. */
a       { color: var(--sc-brand-on-light); }
a:hover { color: var(--sc-brand-on-light-hover); }

/* The principal button — 50 instances across app/views/, the product's most-used
   coloured surface, and the one the design's mapping table never named. Reachable only
   through `.btn-primary`'s own `--bs-btn-*` locals: a `:root` value for
   `--bs-btn-hover-bg` is inert because all 16 button variants redeclare it inside their
   own class block.
   ⚠ THIS REPAINTS THE DEFAULT BUTTON, DELIBERATELY, AND THE DECISION WAS PARKED HERE.
   The core-chrome note at the foot of this file says of `.btn-primary`'s 4.50: "moving
   it repaints the default theme's principal button, which is a design decision... it
   belongs to the default-theme slice." This is that slice. Denis's decision 2026-08-05:
   wire it. White on #0d6efd measured 4.50 — AA by a margin of 0.00; white on #0a58ca
   measures 6.44. The alternative (leave it hardcoded) keeps the number and leaves the
   product's most-used coloured surface outside the contract entirely. */
.btn-primary {
    --bs-btn-bg: var(--sc-brand-on-light);
    --bs-btn-border-color: var(--sc-brand-on-light);
    --bs-btn-hover-bg: var(--sc-brand-on-light-hover);
    --bs-btn-hover-border-color: var(--sc-brand-on-light-hover);
    --bs-btn-active-bg: var(--sc-brand-on-light-hover);
    --bs-btn-active-border-color: var(--sc-brand-on-light-hover);
}

/* The site chrome — core's navbar and footer.

   ⚠⚠ A CORE-OWNED CLASS, AND THAT IS STRUCTURAL SAFETY RATHER THAN A SPECIFICITY
   ACCIDENT. `.bg-light` cannot be re-pointed at a token: it resolves `--bs-light-rgb`
   (another triple hook — a hex there measured a TRANSPARENT navbar) and it is declared
   `!important`, which no ordinary rule can beat. So the two core partials carry
   `.sc-chrome` instead. Those partials are exactly the files a client layer overrides
   wholesale — AnJil's ships its own `theme/views/partials/{header,footer}.php` — so
   these rules can never reach a themed install AT ALL. That is A4's structural property,
   not A1's specificity argument, and `ThemeTokenContractTest` pins the override so the
   claim fails loudly if a future layer stops overriding one of them.

   `--bs-navbar-color` is set here rather than relying on `.sc-chrome a`, because
   `.navbar-nav .nav-link` is (0,2,0) and would beat it.
   ⚠ HOVER AND ACTIVE ARE DELIBERATELY LEFT TO BOOTSTRAP, and it is a KNOWN LIMIT rather
   than an oversight: there is no `-on-dark-hover` token and inventing one would be a
   ninth token, i.e. a new decision. On core's light default the inherited hover is
   correct; a client who sets `--sc-ink` genuinely dark should also set
   `--bs-navbar-hover-color`, which the manual says.

   ⚠ EACH CHROME SURFACE IS DRIVEN BY THE VARIABLE BOOTSTRAP DESIGNED FOR IT, and that
   is a correction rather than a preference. The first draft used one broad
   `.sc-chrome a` rule; measured on the bare layer, it ALSO repainted the navbar BRAND —
   the studio's own name — because `.sc-chrome a` is (0,1,1) and beats `.navbar-brand`
   (0,1,0). The rendered result was defensible and the mechanism was an accident, so the
   brand is now set deliberately through `--bs-navbar-brand-color` and the bare-anchor
   rule is scoped to `footer.sc-chrome a`, where the footer's legal links really are
   unclassed <a> elements. ⚠ THE BRAND MUST FOLLOW THIS TOKEN, not stay near-black:
   `--bs-navbar-brand-color` resolves to an opaque emphasis colour, so a client who sets
   `--sc-ink` genuinely dark would otherwise render their studio's name black on it. */
.sc-chrome {
    background-color: var(--sc-ink);
    --bs-navbar-color: var(--sc-brand-on-dark);
    --bs-navbar-brand-color: var(--sc-brand-on-dark);
}
footer.sc-chrome a { color: var(--sc-brand-on-dark); }

/* 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; }
    /* ⚠ WHAT THIS RESET CANCELLED IS GONE, AND THE RULE IS KEPT ANYWAY — deliberately.
       It was written when these admin tables were 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 had to go or every card sat inside a second,
       outer box. A3 (2026-08-05) removed that attribute from all 15 core tables and
       dressed them .table, and Bootstrap's .table sets a border-COLOR on the table with
       no width or style — so there is no table border left to cancel.
       MEASURED, not assumed: the rule was disabled through the CSSOM on the live stacked
       roster at 390 and every reading was identical — table border 0px none, tr border
       1px solid, td border 0px none, table box 366 x 9936 with and without it.
       Kept because a CLIENT theme may still set a table border, and A3's own finding is
       that a themed install moves for classes core adds; a dead 1-line reset scoped to
       the stacked-card mode is cheaper than the mobile-only double box it prevents. */
    .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; }
/* ⚠ A5 (2026-08-05): max-width HERE NEVER FIXED THE OVERFLOW, AND IT LOOKED LIKE IT SHOULD.
   The input carries size="60", so its PREFERRED width was 490px. Its containing block is a
   flex item with min-width:auto, which therefore also sized to 490px — so `max-width: 100%`
   resolved against 490px and capped nothing. Circular, and invisible in the CSS.
   MEASURED at 390px: /admin/blog overflowed the page by 112px with this rule already live.
   The fix was .form-control on the input, which sets `width: 100%` and replaces the
   preferred width, breaking the circularity: input 490 -> 366, page overflow 112px -> 0px.
   This rule is KEPT — the monospace/size half is still doing work, and max-width is correct
   defence for any install whose theme drops .form-control. */
.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.

   ⚠⚠ SUPERSEDED 2026-08-05 (B1) — REWRITTEN HERE RATHER THAN CONTRADICTED BELOW.
   Core NO LONGER overrides --bs-link-color-rgb: that :root block is gone, and the
   two hexes this section argues for are now the --sc-brand-on-light /
   --sc-brand-on-light-hover tokens at the TOP of this file, consumed by core's own
   bare `a` / `a:hover` rules. EVERY COLOUR AND EVERY RATIO BELOW STILL HOLDS —
   #0a58ca resting, #084298 hover, on all eight grounds — only the vehicle moved,
   because a HEX token cannot be fed into an rgb-TRIPLE hook (measured: it produces
   black links site-wide). The override could not simply stay beside the new rule:
   the `a` rule wins on source order, so the *-rgb value would have become a number
   CoreChromeContrastTest grades and nothing renders. That test now reads the token.

   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.

   ✅ AND THAT SLICE ARRIVED: B1, 2026-08-05. `.btn-primary` IS now repainted —
   Denis's decision, taken against the measurement — by routing its fill through
   `--sc-brand-on-light` at the top of this file. White on #0a58ca measures 6.44,
   so the zero-margin 4.50 is gone. `<code>` is UNTOUCHED and still measures 4.50:
   it carries no client-brand meaning, so there was nothing to route it through and
   repainting it would have been a design change nobody asked for.
   --------------------------------------------------------------------------- */

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