﻿/* ============================================================
   newsletter.css â€” SINGLE SOURCE OF TRUTH for "rendered newsletter"
   styling (the newsletter card + its element render: header, sections,
   headings, text, buttons, images, grids, flyers, events, quotes,
   dividers, footer).

   Imported by BOTH:
     - frontend/src/screens/builder/builder.css  (the React builder PREVIEW)
     - app/templates/public/base_public.html      (the server public page;
                                                    wired in a later task)
   so the builder preview and the public newsletter page render IDENTICALLY
   from one place.

   This file CONSUMES design tokens that the HOST provides â€” it deliberately
   declares NO :root{} token block. Tokens consumed include (non-exhaustive):
     --ink, --ink-soft, --ink-faint, --brand, --brand-deep, --brand-tint,
     --brand-soft, --accent, --coral, --coral-soft, --leaf, --leaf-soft,
     --line, --line-soft, --bg-sunk, --font-display, --font-ui,
     --r-* (radii), --shadow-* (shadows).
   In the SPA preview these come from frontend/src/styles/tokens.css; on the
   public page they will come from an inline token block on base_public.html.

   The `.mobile .nl-*` rules use the preview device's `.mobile` class; they are
   harmless on the public side (which never sets `.mobile`) and are kept here so
   the render rules live in exactly one place.

   AA note: several token VALUES were darkened for WCAG 2.1 AA (see tokens.css);
   this file references the tokens, so it inherits those AA-safe values.
   ============================================================ */

/* rendered newsletter */
.nl { color: oklch(0.27 0.02 245); font-size: var(--fs, 16px); line-height: 1.6; }
.nl-header {
  position: relative; padding: 46px 38px; color: #fff; overflow: hidden;
  background-size: cover; background-position: center;
}
/* AA scrim: an always-on darker overlay so the white title/eyebrow/date clear
   WCAG 2.1 AA (>=4.5:1) over ANY banner color or preset. For a SOLID banner
   color, --scrim-a/--scrim-a2 are computed server-side per color (see
   scrim_alphas_for in app/services/branding.py) so a light brand color still
   gets the deepest scrim it needs while a dark brand color isn't needlessly
   darkened toward black. Presets and uploaded images don't set these vars, so
   they fall back to the original fixed values (.55/.5) unchanged. The builder
   preview inherits this same scrim (it bakes none of its own), so preview and
   public agree. */
.nl-header::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(rgba(0,0,0,var(--scrim-a,.55)), rgba(0,0,0,var(--scrim-a2,.5)));
}
/* The title block stacks its lines (eyebrow / title / date) as a column; with the
   CARD treatment each line becomes its own "pill" (see the pill rule below), so
   align-items:flex-start lets every pill hug its text instead of stretching full. */
.nl-header .nl-header-in {
  position: relative; z-index: 1;
  display: flex; flex-direction: column; align-items: flex-start; gap: 9px;
}
/* The overlay groups the logo + title card and sits above the scrim. In normal flow
   for preset/solid banners; image mode (below) pulls it onto the image's bottom. */
.nl-header-overlay { position: relative; z-index: 1; }

/* CARD title treatment (--card): instead of DIMMING the whole banner with the
   scrim, show the banner at FULL BRIGHTNESS (the per-line pills carry their own
   contrast). Used by uploaded IMAGE banners always (we never dim a photo), and by
   preset/solid banners when the author opts in (the scrim look is the default for
   those). The pills themselves are styled by the universal pill rule below. */
.nl-header--card::after { display: none; }
.nl-header--card { text-align: left; }
/* Uploaded banner IMAGE: a fixed 16:8 band. object-fit:cover crops exactly ONE
   axis, and object-position on the uncropped axis is a no-op — so the author's
   single image_pos value (emitted inline as "P% P%") slides a tall image
   vertically, a wide panorama horizontally, and does nothing visible on a
   near-2:1 image. The card (always present for an image) floats over the
   band's bottom-left corner. */
.nl-header--image {
  padding: 0; aspect-ratio: 16 / 8;
  background: var(--brand-primary, #1031A3);
}
.nl-header-img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* Desktop: the logo + title card overlay sits ON the image, anchored bottom-left.
   Stacked as a flex column (logo on the image, card just below it) so the logo is
   never hidden behind the card, while the card stays on the image (not below it). */
.nl-header--image .nl-header-overlay {
  position: absolute; left: 24px; right: 24px; bottom: 20px;
  display: flex; flex-direction: column; align-items: flex-start;
}
/* CENTER title placement (hero.title_position = "center"): the logo + title block is
   centered horizontally AND vertically on the banner instead of hugging a corner. The
   default ("corner") is unchanged. This is a DESKTOP layout; it sits BEFORE the mobile
   blocks below so an image banner's mobile rules still win at narrow widths (mobile
   keeps its own logo-top-left / text-bottom layout). It overrides the --card text-align
   (which is left) by appearing later in the file. */
.nl-header--center .nl-header-overlay {
  display: flex; flex-direction: column; align-items: center; text-align: center;
}
.nl-header--center .nl-header-in { text-align: center; align-items: center; }
.nl-header--center .nl-header-logo { margin-left: auto; margin-right: auto; }
/* Preset/solid (non-image) banner: center within a min-height hero so there is room to
   center vertically (a preset banner otherwise just hugs its text). */
.nl-header--center:not(.nl-header--image) {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  min-height: 200px;
}
/* Image banner: re-anchor the absolutely-positioned overlay to the image's center. */
.nl-header--center.nl-header--image .nl-header-overlay {
  top: 50%; bottom: auto; transform: translateY(-50%); align-items: center;
}
/* ---- MOBILE image banner ------------------------------------------------------
   On a narrow screen the image goes full-bleed (edge to edge) and EVERYTHING sits ON
   the image: the logo in the top-left corner, and the header text as CENTERED brand
   "pills" (each line â€” eyebrow / title / date â€” its own card hugging its text) along
   the bottom. Nothing renders below the image, so there is no leftover band.
   The mechanism: the header is the positioning context and, since the image is the only
   in-flow child, the header is exactly the image's box. The overlay is display:contents
   so the logo + text position against that box; the logo anchors top-left and the text
   anchors bottom, both over the image regardless of its natural height. The preview uses
   the .mobile class; the public page mirrors this via the @media block below. Desktop is
   unchanged, and this only applies when there is an uploaded image (--image). */
.mobile .nl-header--image { position: relative; padding: 0; min-height: 0; background: transparent; }
.mobile .nl-header--image .nl-header-overlay { display: contents; }
.mobile .nl-header--image .nl-header-logo {
  position: absolute; top: 12px; left: 12px; margin: 0; z-index: 2; max-height: 54px;
}
.mobile .nl-header--image .nl-header-in {
  position: absolute; left: 0; right: 0; bottom: 12px; margin: 0; padding: 0 12px;
  background: transparent !important; max-width: none; z-index: 2;
  display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
}
.mobile .nl-header--image .nl-eyebrow,
.mobile .nl-header--image .nl-header-in h1,
.mobile .nl-header--image .subtitle,
.mobile .nl-header--image .byline,
.mobile .nl-header--image .nl-date {
  margin: 0; display: inline-block; max-width: 100%;
  background: var(--banner-title, var(--brand-primary, #1031A3)); color: #fff;
  padding: 5px 12px; border-radius: 8px; text-shadow: none; opacity: 1;
}
.mobile .nl-header--image .nl-header-in h1 { font-size: 1.1875em; line-height: 1.15; }
/* Reverse colors on mobile: invert each pill to a white card with brand-color text
   (symmetric contrast, so still AA), matching the desktop --invert card. */
.mobile .nl-header--image.nl-header--invert .nl-eyebrow,
.mobile .nl-header--image.nl-header--invert .nl-header-in h1,
.mobile .nl-header--image.nl-header--invert .subtitle,
.mobile .nl-header--image.nl-header--invert .byline,
.mobile .nl-header--image.nl-header--invert .nl-date {
  background: #fff; color: var(--banner-title, var(--brand-primary, #1031A3));
}
/* SINGLE card on a mobile image banner: collapse the per-line pills into one
   centered card hugging the bottom (4-class selectors out-specify the pill rules
   above; the container override beats the !important transparent with its own
   !important + higher specificity). */
.mobile .nl-header--image.nl-header--single .nl-header-in {
  left: 50%; right: auto; transform: translateX(-50%);
  width: max-content; max-width: calc(100% - 24px);
  background: var(--banner-title, var(--brand-primary, #1031A3)) !important;
  padding: 12px 16px; border-radius: 10px;
}
.mobile .nl-header--image.nl-header--single.nl-header--invert .nl-header-in {
  background: #fff !important;
}
.mobile .nl-header--image.nl-header--single .nl-eyebrow,
.mobile .nl-header--image.nl-header--single .nl-header-in h1,
.mobile .nl-header--image.nl-header--single .subtitle,
.mobile .nl-header--image.nl-header--single .byline,
.mobile .nl-header--image.nl-header--single .nl-date {
  background: none; color: #fff;
}
.mobile .nl-header--image.nl-header--single.nl-header--invert .nl-eyebrow,
.mobile .nl-header--image.nl-header--single.nl-header--invert .nl-header-in h1,
.mobile .nl-header--image.nl-header--single.nl-header--invert .subtitle,
.mobile .nl-header--image.nl-header--single.nl-header--invert .byline,
.mobile .nl-header--image.nl-header--single.nl-header--invert .nl-date {
  color: var(--banner-title, var(--brand-primary, #1031A3));
}
/* Public-page twin of the mobile image-banner layout (scoped to .nl-page so it only
   affects the published page, where mobile is a real narrow viewport rather than the
   preview's .mobile class). Same design as the .mobile rules above. */
@media (max-width: 600px) {
  .nl-page .nl-header--image { position: relative; padding: 0; min-height: 0; background: transparent; }
  .nl-page .nl-header--image .nl-header-overlay { display: contents; }
  .nl-page .nl-header--image .nl-header-logo {
    position: absolute; top: 12px; left: 12px; margin: 0; z-index: 2; max-height: 54px;
  }
  .nl-page .nl-header--image .nl-header-in {
    position: absolute; left: 0; right: 0; bottom: 12px; margin: 0; padding: 0 12px;
    background: transparent; max-width: none; z-index: 2;
    display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center;
  }
  .nl-page .nl-header--image .nl-eyebrow,
  .nl-page .nl-header--image .nl-header-in h1,
  .nl-page .nl-header--image .subtitle,
  .nl-page .nl-header--image .byline,
  .nl-page .nl-header--image .nl-date {
    margin: 0; display: inline-block; max-width: 100%;
    background: var(--banner-title, var(--brand-primary, #1031A3)); color: #fff;
    padding: 5px 12px; border-radius: 8px; text-shadow: none; opacity: 1;
  }
  .nl-page .nl-header--image .nl-header-in h1 { font-size: 1.1875em; line-height: 1.15; }
  .nl-page .nl-header--image.nl-header--invert .nl-eyebrow,
  .nl-page .nl-header--image.nl-header--invert .nl-header-in h1,
  .nl-page .nl-header--image.nl-header--invert .subtitle,
  .nl-page .nl-header--image.nl-header--invert .byline,
  .nl-page .nl-header--image.nl-header--invert .nl-date {
    background: #fff; color: var(--banner-title, var(--brand-primary, #1031A3));
  }
  /* SINGLE card on a public mobile image banner (twin of the .mobile rules). */
  .nl-page .nl-header--image.nl-header--single .nl-header-in {
    left: 50%; right: auto; transform: translateX(-50%);
    width: max-content; max-width: calc(100% - 24px);
    background: var(--banner-title, var(--brand-primary, #1031A3)) !important;
    padding: 12px 16px; border-radius: 10px;
  }
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .nl-header-in {
    background: #fff !important;
  }
  .nl-page .nl-header--image.nl-header--single .nl-eyebrow,
  .nl-page .nl-header--image.nl-header--single .nl-header-in h1,
  .nl-page .nl-header--image.nl-header--single .subtitle,
  .nl-page .nl-header--image.nl-header--single .byline,
  .nl-page .nl-header--image.nl-header--single .nl-date {
    background: none; color: #fff;
  }
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .nl-eyebrow,
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .nl-header-in h1,
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .subtitle,
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .byline,
  .nl-page .nl-header--image.nl-header--single.nl-header--invert .nl-date {
    color: var(--banner-title, var(--brand-primary, #1031A3));
  }
}
/* Reversed title card: a LIGHT (white) card with brand-color text, the inverse of
   the default brand card + white text. Contrast is symmetric, so if the default
   (white-on-brand) is AA then this (brand-on-white) is AA at the identical ratio.
   The dark drop-shadow on the title is for white-on-photo legibility â€” drop it on
   the light card. The preview sets the white bg + brand text color inline (dynamic
   brand color); these rules cover the shared bits (shadow, full-opacity subtext).
   The public page reads the brand color from var(--brand-primary). */
.nl-header--invert .nl-header-in h1 { text-shadow: none; }
/* Per-school branding logo chip in the hero. Shared by the public page and the
   SPA builder preview so both render the logo identically (a white-padded chip
   over the scrim). */
/* The logo sits ON the banner (it is a sibling of the title card, never inside it),
   so its PNG transparency shows the banner art/photo/color, NOT a solid card fill.
   No backing box â€” a subtle drop-shadow (which follows the logo's own alpha shape,
   so it preserves transparency) keeps it legible over busy art. Left-aligned to match
   the title; sits above the scrim via z-index. */
.nl-header-logo {
  display: block; margin: 0 0 1rem; max-height: 72px; width: auto;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .35));
}
/* Per-newsletter logo size (hero.logo_size: sm/md/lg). One definition here,
   shared by the builder preview (@import) and the public page (<link>). */
.nl-header-logo--sm { max-height: 48px; }
.nl-header-logo--md { max-height: 72px; }
.nl-header-logo--lg { max-height: 104px; }
.nl-eyebrow {
  font-size: .75em; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;
  opacity: .92; margin-bottom: 10px;
}
.nl-header h1 {
  /* Default heading font = the body/UI face the eyebrow + date use (Hanken Grotesk,
     system fallback on the public page). A decorative font (nl-tf-<key>) overrides it. */
  font-family: var(--font-body); font-weight: 700;
  font-size: clamp(1.625em, 4vw, 2.375em); line-height: 1.08; letter-spacing: -0.02em;
  margin: 0; text-shadow: 0 2px 10px rgba(0,0,0,.25);
  text-wrap: balance;
}
.nl-date { margin-top: 12px; font-size: .875em; font-weight: 600; opacity: .95; }

/* ---- decorative HEADING fonts (self-hosted, OFL) ------------------------------
   Opt-in per newsletter (hero.title_font). The class (nl-tf-<key>) sits on the .nl
   article so it styles BOTH the banner <h1> AND every section heading (.nl-h) for a
   cohesive themed look. Headings stay REAL TEXT (selectable, translatable, screen-
   reader-readable); we only swap the font-family, so the brand-color + scrim/pills
   contrast guarantees are untouched and they stay AA. Self-hosted woff2
   (app/static/fonts/) so the PUBLIC page has no external font dependency. Allowlisted
   class; unknown values fall back to --font-display. Lazy (font-display:swap) -- only
   fetched when a newsletter uses one. Scripts reset the base negative tracking and get
   extra line-height for their descenders. */
@font-face { font-family: "Cinzel";          src: url(fonts/cinzel.woff2)    format("woff2"); font-weight: 600; font-display: swap; }
@font-face { font-family: "Playfair Display"; src: url(fonts/playfair.woff2)  format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Fredoka";         src: url(fonts/fredoka.woff2)   format("woff2"); font-weight: 600; font-display: swap; }
@font-face { font-family: "Anton";           src: url(fonts/anton.woff2)     format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Righteous";       src: url(fonts/righteous.woff2) format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Pacifico";        src: url(fonts/pacifico.woff2)  format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Lobster";         src: url(fonts/lobster.woff2)   format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Caveat";          src: url(fonts/caveat.woff2)    format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Permanent Marker"; src: url(fonts/marker.woff2)   format("woff2"); font-weight: 400; font-display: swap; }

/* Shared: each key sets the family + tracking/weight on the banner title AND the
   section H2 headings ONLY. `.nl-h:not(.sm)` deliberately EXCLUDES the inline
   Subheading element (`.nl-h.sm`) so subheadings + body/paragraph text keep the
   default font â€” only the big section headings take the decorative face. */
.nl-tf-cinzel    .nl-header h1, .nl-tf-cinzel    .nl-h:not(.sm) { font-family: "Cinzel", Georgia, serif; font-weight: 600; letter-spacing: .01em; }
.nl-tf-playfair  .nl-header h1, .nl-tf-playfair  .nl-h:not(.sm) { font-family: "Playfair Display", Georgia, serif; font-weight: 700; letter-spacing: 0; }
.nl-tf-fredoka   .nl-header h1, .nl-tf-fredoka   .nl-h:not(.sm) { font-family: "Fredoka", system-ui, sans-serif; font-weight: 600; letter-spacing: 0; }
.nl-tf-anton     .nl-header h1, .nl-tf-anton     .nl-h:not(.sm) { font-family: "Anton", Impact, sans-serif; font-weight: 400; letter-spacing: .01em; text-transform: uppercase; }
.nl-tf-righteous .nl-header h1, .nl-tf-righteous .nl-h:not(.sm) { font-family: "Righteous", system-ui, sans-serif; font-weight: 400; letter-spacing: .01em; }
.nl-tf-pacifico  .nl-header h1, .nl-tf-pacifico  .nl-h:not(.sm) { font-family: "Pacifico", cursive; font-weight: 400; letter-spacing: 0; }
.nl-tf-lobster   .nl-header h1, .nl-tf-lobster   .nl-h:not(.sm) { font-family: "Lobster", cursive; font-weight: 400; letter-spacing: 0; }
.nl-tf-caveat    .nl-header h1, .nl-tf-caveat    .nl-h:not(.sm) { font-family: "Caveat", cursive; font-weight: 700; letter-spacing: 0; }
.nl-tf-marker    .nl-header h1, .nl-tf-marker    .nl-h:not(.sm) { font-family: "Permanent Marker", cursive; font-weight: 400; letter-spacing: 0; }

/* Script/handwriting fonts need extra leading on the big banner title; Caveat's small
   x-height also wants a size bump on both the title and section headings. */
.nl-tf-pacifico .nl-header h1, .nl-tf-lobster .nl-header h1 { line-height: 1.25; }
.nl-tf-caveat   .nl-header h1 { line-height: 1.1; font-size: clamp(2.125em, 5.2vw, 3.125em); }
/* Caveat's small x-height reads tiny at the default heading size, so the section
   heading gets a larger size than other fonts (well above the 24px base). Section
   H2 only (`:not(.sm)`) â€” the inline Subheading keeps the default font + size. */
.nl-tf-caveat   .nl-h:not(.sm) { font-size: 2.25em; line-height: 1.15; }

/* ---- header pills (CARD treatment only) ---------------------------------------
   When the CARD treatment is on â€” an uploaded image banner (always), or a preset/
   solid banner where the author opted into "solid card behind title" â€” each header
   line (eyebrow / title / date) becomes its own brand "card" hugging its text, in
   both desktop + mobile. White text on brand_primary is AA (brand_primary is the
   school banner color, gated dark enough for white text); --invert flips each pill
   to a white card with brand-color text (symmetric ratio, still AA). brand_primary
   is dynamic: the preview sets --brand-primary on the preview root, the public page
   injects it on <body>. DELIBERATE (2026-07-31 fix-wave, finding 1): these rules
   read --brand-primary ONLY, never the header-scoped --banner-art-color a -brand
   design's picked color sets (hero-bgs.css) -- an arbitrary author-picked color is
   NOT AA-gated, so letting it repaint these pills would break the white-on-brand
   guarantee this comment describes. The picked color tints the design ART only;
   pills keep today's school-color AA behavior unconditionally. When the card is OFF
   the lines stay plain (scrim + white text). Selectors are scoped under
   .nl-header--card so they out-specify the base line rules (and base_public's
   .nl-header .nl-date) and win on margin/opacity. */
.nl-header--card:not(.nl-header--single) .nl-eyebrow,
.nl-header--card:not(.nl-header--single) .nl-header-in h1,
.nl-header--card:not(.nl-header--single) .subtitle,
.nl-header--card:not(.nl-header--single) .byline,
.nl-header--card:not(.nl-header--single) .nl-date {
  display: inline-block; max-width: 100%; margin: 0;
  background: var(--banner-title, var(--brand-primary, #1031A3)); color: #fff;
  padding: 6px 14px; border-radius: 10px; text-shadow: none; opacity: 1;
}
.nl-header--card.nl-header--invert:not(.nl-header--single) .nl-eyebrow,
.nl-header--card.nl-header--invert:not(.nl-header--single) .nl-header-in h1,
.nl-header--card.nl-header--invert:not(.nl-header--single) .subtitle,
.nl-header--card.nl-header--invert:not(.nl-header--single) .byline,
.nl-header--card.nl-header--invert:not(.nl-header--single) .nl-date {
  background: #fff; color: var(--banner-title, var(--brand-primary, #1031A3));
}

/* ---- header card: SINGLE card (one card behind all the title lines) -----------
   The alternative to per-line pills (nl-header--single). One brand card wraps the
   whole title block; the lines inside carry no background and inherit white text
   (brand-color text when --invert). inline-flex so the card hugs its content; the
   .nl-header-in column gap spaces the lines. Works for image + preset/solid banners
   at desktop width; the mobile-image override below handles the narrow layout. */
.nl-header--single .nl-header-in {
  display: inline-flex; max-width: 100%;
  background: var(--banner-title, var(--brand-primary, #1031A3));
  padding: 14px 18px; border-radius: 12px;
}
.nl-header--single .nl-eyebrow,
.nl-header--single .nl-header-in h1,
.nl-header--single .subtitle,
.nl-header--single .byline,
.nl-header--single .nl-date {
  margin: 0; background: none; color: #fff; text-shadow: none; opacity: 1;
}
.nl-header--single.nl-header--invert .nl-header-in {
  background: #fff;
}
.nl-header--single.nl-header--invert .nl-eyebrow,
.nl-header--single.nl-header--invert .nl-header-in h1,
.nl-header--single.nl-header--invert .subtitle,
.nl-header--single.nl-header--invert .byline,
.nl-header--single.nl-header--invert .nl-date {
  color: var(--banner-title, var(--brand-primary, #1031A3));
}

/* ---- banner title colour (--banner-title, 2026-08-05) ------------------------
   The per-newsletter heading colour reaches the banner too. Nothing else in the
   app could set it: the banner's own colour controls paint its BACKGROUND.

   IT TAKES THE WHOLE TITLE BLOCK, NOT THE h1 ALONE (Mark, 2026-08-05, on seeing
   the first cut). The eyebrow, subtitle, byline and date are pills in the SAME
   stack as the h1; colouring one pill left a two-tone stack that read as a bug.
   Where the colour is a BACKGROUND the whole block takes it; where it is TEXT
   (the scrim treatment, last rule here) only the h1 does, exactly like the page
   body, where headings are coloured and the text around them is not.

   The h1 does not sit on the white page like the section headings, so the server
   solves the value against the surface it actually lands on before emitting it
   (app/public/routes.py); the rules only place it:

     card / pill  -> PAINT the pills; the text stays white. The server sends the
                     AA-darkened colour, and "colour vs white" is one ratio, so
                     the same value is equally safe as text on the inverted
                     white card.
     single card  -> paint the card itself (its lines carry no background).
     scrim        -> colour the h1 TEXT, lightened server-side until it clears AA
                     over the banner as the scrim leaves it.

   The card arms live INLINE in the pill/card rules above (desktop, .mobile and
   the @media block all carry the same var, so a phone-width image banner is not
   left behind), each keeping var(--brand-primary) as its fallback so a
   newsletter with no heading colour is byte-identical. Only the scrim arm needs
   a rule of its own: */
.nl-header:not(.nl-header--card) .nl-header-in h1 { color: var(--banner-title, inherit); }

.nl-body { padding: 30px 38px 40px; }
/* :not(--image) so an image banner keeps its edge-to-edge padding:0 on mobile
   (this rule and .mobile .nl-header--image have equal specificity otherwise). */
.mobile .nl-header:not(.nl-header--image) { padding: 34px 24px; }
.mobile .nl-body { padding: 24px 22px 32px; }

.nl-sec { margin: 0 0 26px; }
.nl-sec:last-child { margin-bottom: 0; }
/* Social icon row (2026-08-03): consecutive icon-only social buttons are
   grouped by social_runs()/socialRuns() into ONE flex container. Flex
   ignores whitespace-only text nodes, so the server (newline-separated)
   and JSX (adjacent) renders lay out identically. Child .nl-sec margins
   are zeroed: the row owns spacing (gap) and the section rhythm (26px). */
.nl-socrow { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0 0 26px; }
.nl-socrow:last-child { margin-bottom: 0; }
.nl-socrow > .nl-sec { margin: 0; }
/* Section headings (h2). Default font = the body face (matches the eyebrow/date);
   a decorative nl-tf-<key> overrides it. The base size (24px) is set explicitly so a
   plain section heading is visibly larger than the subheading (.nl-h.sm, 17px) for a
   clear visual hierarchy one level down. The .lg/.md/.sm modifiers keep their own sizes. */
.nl-h { font-family: var(--font-body); font-weight: 700; font-size: 1.75em; letter-spacing: -0.01em; color: var(--ink); margin: 0 0 12px; text-wrap: balance; }
.nl-h.lg { font-size: 1.625em; }
.nl-h.md { font-size: 1.3125em; }
.nl-h.sm { font-size: 1.0625em; }
/* Per-newsletter heading color (hero.heading_color == “brand” or a picked hex):
   --heading is the AA-darkened value, set on the article (public) / preview root
   (SPA). Unset var -> same ink as today.

   WIDENED 2026-08-05 (Mark: "it should change all headings"). This reaches every
   heading that sits ON THE PAGE BACKGROUND — the section h2, the Subheading
   element, and the plain Event title. Before this only the section h2 was
   painted, so an author's Subheading came out ink-black directly beneath a
   brand-colored heading.

   Selectors are TAG-FREE as of 2026-08-05 (heading-depth work): the level
   of a Subheading or an Event title now follows the document (both drop to
   h2 inside a section with no heading of its own), so matching on h3 would
   silently stop painting them.

   FENCE, deliberate: the titles inside STYLED flyer/event callouts
   (.fl-* in flyer-styles.css) keep their own palettes. Each callout style
   solves its text color against its own background; --heading has no
   relationship to that background, so nothing would guarantee AA. Same fence
   already held for button colors, banner color, and link color. */
.nl-section > h2.nl-h,
.nl-h.sm,
.nl-event .nl-ev-title { color: var(--heading, var(--ink)); }
/* flow-root contains a wrapped (floated) image so it never bleeds past the paragraph
   into the next element. The paragraph font is the inherited default body face â€” the
   decorative title font is scoped to headings and never reaches body text. */
.nl-text { display: flow-root; }
/* An image the paragraph text wraps around (text element, image_side left/right).
   The author's size applies on the narrow/mobile render too (Mark 2026-07-30):
   N px of a phone column is simply clamped by max-width, same wrap. width (not
   max-width) + img width:100% so the chosen size is the ACTUAL rendered width —
   a source file narrower than the chosen size scales up to it; otherwise two
   "M" images render different sizes (Mark's screenshot, 2026-07-30). Twin of
   the public page, whose old @media stack rule was removed the same day. */
/* The text-side margin is 28px, not the 16px a plain paragraph would need,
   because of LISTS (Mark, 2026-08-05). A float shortens line boxes, not block
   boxes, so a <ul> beside one keeps its full-width box and its padding-left
   buys nothing; the first line box starts at the float's right edge plus this
   margin, and an outside bullet is painted to the LEFT of that start. The
   bullet therefore sits inside this margin, so it has to hold the marker
   (about 15px) plus a visible gap, or the bullets butt against the image
   border. Pinned in tests/test_public_page_shell.py. */
.nl-text .nl-figure.float_left  { float: left;  width: var(--figw, 46%); max-width: 100%; margin: 4px 28px 8px 0; }
.nl-text .nl-figure.float_right { float: right; width: var(--figw, 46%); max-width: 100%; margin: 4px 0 8px 28px; }
.nl-text .nl-figure.float_left img,
.nl-text .nl-figure.float_right img { width: 100%; }
/* Narrow render: --figw-m is an explicit author phone-size override
   (image_width_m, % of the widest column), emitted ONLY when set. With no
   override phones fall through to --figw unchanged — the picked size
   renders IDENTICAL on phones (capped by max-width; the old derived
   px/7.4 shrink is gone, Mark 2026-08-03). Both variants: .mobile = the
   builder's phone preview (desktop viewport, media query can't fire),
   @media = real phones. */
.mobile .nl-text .nl-figure.float_left,
.mobile .nl-text .nl-figure.float_right { width: var(--figw-m, var(--figw, 46%)); }
@media (max-width: 600px) {
  .nl-text .nl-figure.float_left,
  .nl-text .nl-figure.float_right { width: var(--figw-m, var(--figw, 46%)); }
}
.nl-text p { margin: 0 0 12px; }
.nl-text p:last-child { margin-bottom: 0; }
.nl-text ul { margin: 0 0 12px; padding-left: 22px; }
.nl-text li { margin-bottom: 5px; }
/* Paragraph spacing (2026-07-30 text-wysiwyg §5): author-chosen block gap.
   "normal" is the untouched rules above (no class emitted — see
   _public_item.html / Preview.tsx). Bullets (ul) get the SAME block-gap
   treatment as p; li-to-li spacing inside a list is unaffected. */
.nl-text.ps-compact p,
.nl-text.ps-compact ul { margin-bottom: 4px; }
.nl-text.ps-roomy p,
.nl-text.ps-roomy ul { margin-bottom: 22px; }
.nl-text.ps-compact p:last-child,
.nl-text.ps-compact ul:last-child,
.nl-text.ps-roomy p:last-child,
.nl-text.ps-roomy ul:last-child { margin-bottom: 0; }
.nl-text a { color: var(--accent, var(--brand-deep)); font-weight: 600; }   /* AA: accent (school brand, save-gated AA) or brand-deep fallback */
.nl-text strong { color: var(--ink); }
/* A bold link label is <strong> INSIDE the anchor (inline links 2026-07-31),
   where the emphasis-ink rule above must not beat the link color. */
.nl-text a strong { color: inherit; }

.nl-btn {
  display: inline-flex; align-items: center; gap: 8px;
  /* Button = the AA-darkened school primary (--btn-primary, set on the public
     <body> and the preview root; white text needs 4.5:1); falls back to the raw
     --brand-primary then the default brand token when a school hasn't set one.
     The flyer-callout .nl-btn overrides this with --coral. */
  background: var(--btn-primary, var(--brand-primary, var(--brand))); color: #fff; font-weight: 700; font-size: .875em;
  padding: 8px 16px; border-radius: 999px; text-decoration: none;
  box-shadow: var(--shadow-sm);
}
/* Full-width button choice (data.width == "full", spec 2026-08-03): the button
   spans the content column with its label centered. Composes with the color
   modifiers / inline hex background below; the icon-only social branch and
   text-look links never emit this class. */
.nl-btn--full { display: flex; width: 100%; justify-content: center; }
/* Per-button color choice (data.color, spec 2026-08-03): the AA-darkened school
   secondary/accent, each falling back down the primary chain so a stale choice
   on a school without that color never renders an un-themed button. */
.nl-btn--c-secondary { background: var(--btn-secondary, var(--btn-primary, var(--brand-primary, var(--brand)))); }
.nl-btn--c-accent { background: var(--btn-accent, var(--btn-primary, var(--brand-primary, var(--brand)))); }
/* Social platform icon inside a link button (spec 2026-07-30). The button is
   already inline-flex with an 8px gap, so the svg just needs a size; it
   paints via currentColor = the button's AA text color. */
.nl-btn .nl-soc { width: 1.1em; height: 1.1em; flex: 0 0 auto; }

/* Icon-only social buttons: one large glyph in a 44px circular target.
   The sr-only span carries the accessible name; focus ring must stay visible. */
.nl-btn.nl-socbtn {
  width: 44px; height: 44px; min-width: 44px;
  padding: 0; border-radius: 50%;
  align-items: center; justify-content: center;
}
.nl-socbtn .nl-soc { width: 26px; height: 26px; }

/* Shape/size modifiers (hero.social_shape / hero.social_size). Default =
   circle 44px (the bare .nl-socbtn rules above). Square radius scales with
   the tile: 8/10/12px. Tiles paint their platform's brand hex via an inline
   style from the registry -- the ONE source of truth, no hex duplicated here. */
.nl-socrow--square .nl-socbtn { border-radius: 10px; }
.nl-socrow--square.nl-socrow--s .nl-socbtn { border-radius: 8px; }
.nl-socrow--square.nl-socrow--l .nl-socbtn { border-radius: 12px; }
.nl-socrow--s .nl-socbtn { width: 32px; height: 32px; min-width: 32px; }
.nl-socrow--s .nl-socbtn .nl-soc { width: 19px; height: 19px; }
.nl-socrow--l .nl-socbtn { width: 56px; height: 56px; min-width: 56px; }
.nl-socrow--l .nl-socbtn .nl-soc { width: 33px; height: 33px; }

.nl-link-text { color: var(--accent, var(--brand-deep)); font-weight: 700; text-decoration: none; border-bottom: 2px solid var(--brand-tint); }  /* AA: accent (save-gated AA) or brand-deep */

.nl-img {
  border-radius: 14px; overflow: hidden; border: 1px solid var(--line-soft);
  background:
    repeating-linear-gradient(45deg, oklch(0.95 0.02 233) 0 11px, oklch(0.97 0.015 233) 11px 22px);
  display: grid; place-items: center; aspect-ratio: 16/9; color: var(--brand-deep);
}
.nl-img .nl-img-label {
  font-family: ui-monospace, Menlo, monospace; font-size: .75em; font-weight: 600;
  opacity: .8;
}
.nl-cap { margin-top: 8px; font-size: .8125em; color: var(--ink-faint); text-align: center; }
/* Animated-GIF Pause/Play toggle (injected by gif-pause.js under each
   animated image; WCAG 2.2.2). Still a <button> (it acts) but styled as a
   plain text link (Mark 2026-08-03). AA: ink on white; state is the flipping
   Pause/Play label, never color alone. Default focus outline kept. */
.nl-gifbtn {
  display: inline-block; margin: 6px 0 0; padding: 0;
  font-size: 12.5px; font-weight: 700; line-height: 1.4; letter-spacing: .01em;
  border: 0; background: none; color: var(--ink, #1c2433);
  text-decoration: underline; cursor: pointer;
}
.nl-gifbtn:hover { color: var(--brand-deep, #1031A3); }
.nl-grid { display: grid; gap: var(--figgap, 10px); }
/* Author-set gallery width (image_width != 100 emits .figw + --figw, % or
   px): the whole grid shrinks and centers; photos scale with it. The
   max-width guard keeps a px width from overflowing a narrower column. */
.nl-grid.figw { width: var(--figw, 100%); max-width: 100%; margin-left: auto; margin-right: auto; }
/* Same explicit-override-only --figw-m fallback as the wrap figures above. */
.mobile .nl-grid.figw { width: var(--figw-m, var(--figw, 100%)); }
@media (max-width: 600px) {
  .nl-grid.figw { width: var(--figw-m, var(--figw, 100%)); }
}
.nl-grid.g2 { grid-template-columns: 1fr 1fr; }
.nl-grid.g3 { grid-template-columns: 1fr 1fr 1fr; }
.nl-grid.g4 { grid-template-columns: 1fr 1fr; }
.nl-grid .nl-img { aspect-ratio: 1; }
/* No .mobile 3->2 column reflow: the public page never had one, so the old
   preview-only rule made the mobile preview wrap the third photo while real
   phones showed 3-across (Mark's 2026-07-30 catch). Grids keep their column
   count at every width. */
/* Real uploaded images (the builder-preview twin of the server figure/grid render).
   The public page also carries these in base_public.html; defined here so the SHARED
   source styles the actual photo in the preview, not only the placeholder box. */
.nl-figure { margin: 0 0 1rem; }
.nl-figure img {
  max-width: 100%; height: auto; display: block;
  border-radius: 14px; border: 1px solid var(--line-soft);
}
.nl-grid figure { margin: 0; }
.nl-grid figure img {
  width: 100%; height: auto; display: block;
  border-radius: 14px; border: 1px solid var(--line-soft);
}

/* flyer */
.nl-flyer-upload {
  border-radius: 16px; overflow: hidden; border: 1px solid var(--line-soft);
  background: repeating-linear-gradient(45deg, oklch(0.95 0.03 35) 0 11px, oklch(0.97 0.02 40) 11px 22px);
  aspect-ratio: 3/4; max-width: 320px; margin: 0 auto; display: grid; place-items: center;
  color: var(--ink-soft); text-align: center; gap: 4px;   /* AA: ink-soft, not coral (5.3:1 on tint) */
}
.nl-flyer-upload .lbl { font-family: ui-monospace, Menlo, monospace; font-size: .75em; }
.nl-flyer-callout {
  border-radius: 16px; border: 1.5px solid var(--coral-soft);
  background: linear-gradient(160deg, var(--coral-soft), #fff);
  padding: 22px 24px; display: flex; gap: 18px; align-items: center;
}
.nl-flyer-callout .nl-fc-date {
  flex-shrink: 0; width: 76px; text-align: center; background: var(--coral); color: #fff;
  border-radius: 12px; padding: 10px 6px; box-shadow: var(--shadow-sm);
}
.nl-flyer-callout .nl-fc-date .mon { font-size: .75em; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.nl-flyer-callout .nl-fc-date .day { font-family: var(--font-display); font-size: 1.875em; font-weight: 700; line-height: 1; }
.nl-flyer-callout .nl-fc-body h3 { margin: 0 0 4px; font-family: var(--font-display); font-size: 1.1875em; color: var(--ink); }
.nl-flyer-callout .nl-fc-body .meta { font-size: .84375em; color: var(--ink-soft); margin-bottom: 10px; }
.nl-flyer-callout .nl-btn { background: var(--coral); padding: 9px 18px; font-size: .875em; }
.mobile .nl-flyer-callout { flex-direction: column; text-align: center; align-items: stretch; }
.mobile .nl-flyer-callout .nl-fc-date { width: auto; display: flex; align-items: center; justify-content: center; gap: 8px; }

/* event */
.nl-event {
  display: flex; gap: 16px; align-items: center;
  border: 1.5px solid var(--line); border-left: 4px solid var(--leaf);
  border-radius: 12px; padding: 16px 18px; background: var(--leaf-soft);
}
.nl-event .nl-ev-cal {
  flex-shrink: 0; width: 54px; text-align: center;
  background: #fff; border-radius: 10px; padding: 7px 4px; box-shadow: var(--shadow-sm);
}
.nl-event .nl-ev-cal .mon { font-size: .65625em; font-weight: 700; text-transform: uppercase; color: var(--leaf); letter-spacing: .06em; }
.nl-event .nl-ev-cal .day { font-family: var(--font-display); font-size: 1.5em; font-weight: 700; line-height: 1; color: var(--ink); }
/* NO `color` here. The --heading rule above targets this exact selector at the
   same specificity, so a color declared down here would win on order and kill
   it — which is what happened from the day the picker was widened until
   2026-08-05: the per-newsletter heading colour had never once painted a plain
   Event title. The default lives in that rule's var() fallback. */
.nl-event .nl-ev-title { margin: 0 0 3px; font-family: var(--font-display); font-size: 1.0625em; }
.nl-event .meta { font-size: .84375em; color: var(--ink-soft); }

/* quote */
.nl-quote { border-left: 4px solid var(--brand); padding: 6px 0 6px 20px; }
.nl-quote p { font-family: var(--font-display); font-size: 1.25em; font-style: italic; color: var(--ink); margin: 0 0 8px; line-height: 1.4; }
.nl-quote cite { font-style: normal; font-size: .84375em; font-weight: 700; color: var(--ink-soft); }

/* emoji — a decorative own-line emoji burst (aria-hidden in the markup, like
   the dots/leaf dividers). em-based so it scales with the reader's text-size
   control (--fs), matching the rest of the page typography. line-height 1.5:
   Windows Segoe UI Emoji glyphs ascend past a 1.3 line box and the round
   faces render with their tops clipped at the S/M sizes (Mark, 2026-07-09). */
.nl-emoji { text-align: center; line-height: 1.5; margin: 8px 0; }
.nl-emoji--left { text-align: left; }
.nl-emoji--s  { font-size: 1.5em; }
.nl-emoji--m  { font-size: 2em; }
.nl-emoji--l  { font-size: 3em; }
.nl-emoji--xl { font-size: 4em; }

/* divider */
.nl-divider { border: none; height: 1px; background: var(--line); margin: 8px 0; }
.nl-divider.dots { height: auto; background: none; text-align: center; color: var(--brand); letter-spacing: 10px; font-size: 1.125em; }
.nl-divider.leaf { height: auto; background: none; text-align: center; }

.nl-footer {
  /* Transparent so it reads as the bottom of the white card (the border-top
     separates it). Same in the builder preview and the public page â€” the public
     page is canonical and renders it white. */
  padding: 22px 38px 30px; background: transparent;
  border-top: 1px solid var(--line); color: var(--ink-faint);
  font-size: .78125em; text-align: center; line-height: 1.6;
}
.mobile .nl-footer { padding: 20px 22px; }
.nl-footer a { color: var(--ink-soft); }

.nl-empty-body { padding: 44px 38px; text-align: center; color: var(--ink-faint); }
.nl-empty-body .em { font-size: 1.75em; color: var(--brand); line-height: 0; }

/* Decorative separator between sections: a centered ornament flanked by two rules.
   Rendered as its own element BETWEEN sections (preview + public), so the old
   adjacent-border rule no longer applies. Purely visual -> aria-hidden in the markup;
   the ornament is painted in the school's MAIN brand color, the rules are a defined
   hairline. Three styles (flourish / diamond / dots) are author-selectable per
   newsletter (hero.rule_style -> the .nl-sec-rule--<key> class); the GLYPH is set
   here from CSS content so it never has to live in the JSX + Jinja markup twins. */
.nl-sec-rule {
  display: flex; align-items: center; gap: 16px;
  margin: 34px 0;
}
.nl-sec-rule::before, .nl-sec-rule::after {
  content: ""; flex: 1 1 0; height: 2px; border-radius: 2px;
  background: var(--line);
}
.nl-sec-rule-mark {
  flex: none; font-size: 1.375em; line-height: 1;
  color: var(--brand-primary, #1031A3);
}
/* The ornament glyph per style (default = flourish). */
.nl-sec-rule-mark::before { content: "\2767"; }                          /* â§ flourish */
.nl-sec-rule--diamond .nl-sec-rule-mark::before { content: "\2756"; }    /* â– diamond */
/* dots: three real circles (radial-gradient), so they sit perfectly centered on the
   hairlines -- a text bullet glyph rides high in its line box and looks off-center. */
.nl-sec-rule--dots .nl-sec-rule-mark {
  width: 46px; height: 7px;
  background:
    radial-gradient(circle, currentColor 48%, transparent 52%) left center / 7px 7px no-repeat,
    radial-gradient(circle, currentColor 48%, transparent 52%) center center / 7px 7px no-repeat,
    radial-gradient(circle, currentColor 48%, transparent 52%) right center / 7px 7px no-repeat;
}
.nl-sec-rule--dots .nl-sec-rule-mark::before { content: none; }
/* plain continuous line (no ornament) in two thicknesses: the two hairlines butt
   together (gap:0, mark hidden) to read as one rule, painted in the main brand color. */
.nl-sec-rule--line-thin, .nl-sec-rule--line-thick { gap: 0; }
.nl-sec-rule--line-thin .nl-sec-rule-mark,
.nl-sec-rule--line-thick .nl-sec-rule-mark { display: none; }
.nl-sec-rule--line-thin::before, .nl-sec-rule--line-thin::after {
  height: 1px; background: var(--brand-primary, #1031A3);
}
.nl-sec-rule--line-thick::before, .nl-sec-rule--line-thick::after {
  height: 3px; background: var(--brand-primary, #1031A3);
}
.mobile .nl-sec-rule { margin: 26px 0; }

/* Anchor targets (Contents links) land BELOW the sticky reader toolbar instead
   of scrolling the heading underneath it. Harmless in the SPA preview (no
   sticky toolbar there). */
.nl-section { scroll-margin-top: 72px; }
@media (max-width: 600px) { .nl-section { scroll-margin-top: 116px; } } /* wrapped 2-row toolbar */

/* Contents scrollspy: the link whose section is currently in view (set by
   toc-spy.js as .is-current + aria-current="location"). Weight + a left
   indicator bar + color — NEVER color alone (WCAG 1.4.1). --brand-deep is
   AA on white (defined by both the public shell and the SPA tokens). No JS
   means no link ever carries these, so the base render is untouched. */
.nl-toc a.is-current,
.nl-toc a[aria-current="location"] {
  font-weight: 700;
  color: var(--brand-deep, #1031A3);
  display: inline-block;
  border-left: 4px solid var(--brand-deep, #1031A3);
  padding-left: 8px;
  margin-left: -12px;
}
