/* =========================================================================
   Zeal UI 2.0 — control styles (RCL: _content/Zeal.Maui.App.Ui/css/zt-ui.css)
   Clean, variable-driven, responsive. Palette mirrors the legacy _colors.scss
   but centralized here so colours live in ONE place. The "Font Awesome 6 Pro"
   @font-face is provided by the host (site.css).
   ========================================================================= */

/* =========================================================================
   📐 THE BREAKPOINT LADDER — THE ONLY WIDTHS THIS APP IS ALLOWED TO USE.
   =========================================================================
   ⚠️ CSS CANNOT PUT A VARIABLE IN A MEDIA QUERY (no `@media (max-width: var(…))`,
   and @custom-media has never shipped), and this project has no preprocessor.
   So this comment IS the single source of truth. Copy a number from here; never
   invent one. A new width in this file is a bug even if it looks fine.

   ─ THE FOUR BANDS ────────────────────────────────────────────────────────
     COMPACT    ≤ 599px      every phone in portrait (360–430), a folded cover screen
     MEDIUM     600–839px    ⭐ FOLDABLES UNFOLDED + small tablets portrait + phone landscape
     EXPANDED   840–1199px   tablets landscape, small laptops
     LARGE      ≥ 1200px     desktops

   Written as: @media (max-width: 599px) · (max-width: 839px) · (max-width: 1199px)
   (or min-width: 600 / 840 / 1200 going up).

   ─ WHY THESE NUMBERS AND NOT 1024/720/640 ───────────────────────────────
   They are Material Design 3's window size classes — the one modern ladder
   authored by a vendor that actually SHIPS foldables, so the bands are drawn
   where real devices sit rather than where CRT resolutions used to. Using a
   published standard also means "why 840?" has an answer that isn't taste.

   ⭐ THE MEDIUM BAND IS THE WHOLE POINT, AND IT IS WHY VINCE'S FOLD LOOKED WRONG.
   A Galaxy Z Fold 7 main screen measures 750 x 832 — so 750 wide unfolded, and
   832 wide rotated. BOTH land in MEDIUM, which means the device never changes
   layout class when it is rotated. Under the old single 640px line it was above
   the cutoff and got the full DESKTOP treatment on an 8-inch phone: "not quite
   desktop, not quite mobile" (Vince, 2026-08-16). Medium is not a gap between
   phone and desktop — it is a designed state: a comfortable one-to-two column
   layout with finger-sized targets.

   ─ THE SECOND AXIS: WIDTH IS NOT HORSEPOWER ──────────────────────────────
   ⛔ NEVER use a width query to decide COST. Ask capability separately:
     (pointer: coarse)   touch — tap targets, and what the GPU can afford
     (hover: hover)      a real pointer — hover-only affordances are allowed
   An unfolded foldable is MEDIUM *and* coarse; a 750px-wide desktop window is
   MEDIUM *and* fine. Same room, different budget. Conflating the two is what
   gave a phone the four-band wave backdrop. See CLAUDE.md §3.7a.

   ─ PREFER A CONTAINER QUERY WHEN THE ANSWER IS "HOW BIG IS MY BOX" ───────
   A card that stacks when IT is narrow should use `container-type` + `@container`
   (as .zt-busy-overlay already does), not a viewport width. Viewport queries are
   for PAGE-level structure only.
   ========================================================================= */

/* ---- SELF-HOSTED UI FONT — Inter (variable). Ships from this RCL so consuming
   apps get it automatically; no per-app font include. Inter is built for crisp
   small UI text and renders identically across every OS/browser/device. ------- */
@font-face {
	font-family: "Inter";
	font-style: normal;
	font-weight: 100 900;          /* variable: full weight range in one file */
	font-display: swap;
	src: url("../fonts/inter/inter-latin-wght-normal.woff2") format("woff2");
}

/* GOTHAM — the legacy face, kept and now registered PROPERLY.
   The legacy Zeal.Razor.Ui reboot.scss exposed ONE face, "Gotham Light", and let the browser SYNTHESISE bold.
   ⚠️ THAT SYNTHESIS IS WHY THE TEXT LOOKED FUZZY (Vince, 2026-08-06: "the font looks just really fuzzy and
   weird"). A faux bold is the LIGHT outline smeared sideways by the rasteriser — it has no hinting, so every
   bold label, every value and every button on the site was a blurred approximation of a font that was never
   drawn. Gotham is also a DISPLAY face (WOFF 1.0, no hinting for small sizes), which is the rest of it.
   The real Book / Medium / Bold files were sitting unused next to it, so they are declared here: whichever face
   you ask for now EXISTS and nothing is synthesised. */
@font-face { font-family: "Gotham"; font-weight: 300; font-style: normal; font-display: swap; src: url("../fonts/gotham/gotham-light.woff")  format("woff"); }
@font-face { font-family: "Gotham"; font-weight: 400; font-style: normal; font-display: swap; src: url("../fonts/gotham/gotham-book.woff")   format("woff"); }
@font-face { font-family: "Gotham"; font-weight: 500; font-style: normal; font-display: swap; src: url("../fonts/gotham/gotham-medium.woff") format("woff"); }
@font-face { font-family: "Gotham"; font-weight: 700; font-style: normal; font-display: swap; src: url("../fonts/gotham/gotham-bold.woff")   format("woff"); }

:root {
	/* ⚠️ INTER IS THE UI FACE (2026-08-06). ONE LINE CHANGES IT BACK — put "Gotham" first and Inter second.
	   Inter is self-hosted above as a VARIABLE WOFF2 carrying every weight from 100 to 900, and it was drawn
	   for exactly this job: small interface text, hinted, on any OS. Nothing is synthesised, so a bold label is
	   a real bold and renders sharp. Gotham stays loaded and stays the brand display face — reach for it on a
	   marketing headline, not on a 13px form label. */
	--zt-font: "Inter", "Gotham", system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;

	--zt-item-gap: 3px;   /* GLOBAL tight vertical gap between stacked ZtContentItems — one source of truth. */
	--zeal-green-header: #47795a;   /* softer/muted green for banded card HEADERS — distinct from the vivid button green. */

	--zeal-white: #ffffff;
	--zeal-black: #333333;   /* SOFTENED from #000 — nothing on the site is pure black (Vince: "not super super black") */
	--zeal-grey: #4c4c4c;
	--zeal-medium-grey: #666666;
	--zeal-dark-grey: #333333;   /* SOFTENED from #333 — the dominant body/title text, toned down site-wide */
	--zeal-modal-header: #444444;   /* THE DEFAULT MODAL HEADER BAND (Vince, 2026-08-12) — one global knob */
	--zeal-header-grey: #777777;
	--zeal-light-grey: #bbbbbb;
	--zeal-red: #971b1e;
	--zeal-dark-red: #6b1315;
	--zeal-pink: #d50184;
	--zeal-orange: #f16521;
	--zeal-gold: #ffcb04;
	--zeal-green: #2d602d;
	/* MODERNISED 2026-08-02 (Vince). Was #213f99 — a heavy 1990s navy that read as dated next to everything
	   else on the page. This is lighter and cleaner while still clearing 4.5:1 against white, so it stays safe
	   both as a button background with white text AND as link text on a white page. */
	--zeal-blue: #2563eb;
	--zeal-tiffany: #08bab5;
	--zeal-purple: #3a1856;
	--zeal-brown: #49231c;
	--zeal-silver: #9a9a9a;
	--zeal-bronze: #8b4513;
	/* ── THE LIGHT TONE SET — GREEN · YELLOW · RED · BLUE · GREY ─────────────────────────────────────────
	   THE FIVE SURFACE WASHES, and the only ones. Every tinted panel, band, card and row in the app resolves to
	   one of these; nothing else may introduce a wash of its own.

	   ⚠️ EVERY VALUE IS READ OFF THE LEGACY'S COMPILED OUTPUT — Zeal.Razor.Ui/wwwroot/css/zeal/zeal-ui.css,
	   the .zt-action-item-{green,warning,error,blue} rules that SASS produced from $zealLightGreen /
	   $zealLightGold / $zealLightRed / $zealLightBlue in partials/_colors.scss. NOT computed here, and NOT
	   chosen. Hand-doing the lighten() arithmetic already put the blue a point off per channel — near enough to
	   look right, and still not the colour the rest of the product uses.

	   HOW TO ADD ONE: don't. Take the value from the legacy palette. If it genuinely has no equivalent, ask
	   Vince — the answer is a NAMED entry here, never a hex at the call site.

	   TRANSPARENCY STEP (Vince, 2026-08-04): even the legacy values glared behind content. Each is now HALF its
	   tint — half the distance from white — so all five move together and stay one family. ONE RULE, not five
	   separate picks. Legacy value in each comment; if these ever need another step, halve again FROM THE LEGACY
	   rather than nudging one colour on its own.

	   ⚠️ THE DIRECTION IS TOWARD WHITE. "More opaque" = more solid colour = MORE glare, which is the wrong way.
	   These are a background behind the work; they should register as a tint and never as a colour you look at.

	   DIALLED IN BY EYE, ONE COLOUR AT A TIME — do not "regularise" these onto a shared formula. Every attempt to
	   derive all five from one rule produced a set where some read louder than others, because these five hues do
	   not carry the same perceived weight per unit of tint. YELLOW, BLUE and GREY are SIGNED OFF (Vince,
	   2026-08-04) — leave them exactly as they are.

	   GREEN NEEDS MORE TINT THAN THE OTHER FOUR TO READ AT ALL, and that is not a mistake to "correct". The eye
	   is most sensitive to green, so a green made by holding G near 255 and dropping R and B barely lowers
	   overall lightness — the surface stays near 99% and renders as WHITE however much channel separation it
	   has. Blue gets away with a far smaller delta precisely because dropping R and G DOES lower luminance. So
	   green is cut deeper on all three channels. Its hex distance looks bigger than the others; on screen it is
	   not. Judge these by eye against the /testform swatches, never by comparing hex distances.

	   ⚠️⚠️ NEVER LET PROSE LAND OUTSIDE A COMMENT IN THIS BLOCK. Twice, an edit here closed the comment early and
	   left raw text sitting in :root. CSS then parses that text as a declaration and swallows EVERYTHING up to
	   the next semicolon — which took --zeal-light-green with it. The variable vanished, var() went undefined,
	   and `background: var(--zt-happy-bg)` computed to rgba(0,0,0,0): a WHITE card, while the border (a literal
	   hex) kept working. It cost hours, because grepping the file still finds the text — only the browser knows
	   it was discarded. After ANY edit in here, verify the variable resolves, not that the text is present. */
	--zeal-light-green: #fcfefc;      /* (3,1,3) off white — see the note above */
	--zeal-light-yellow: #fffef9;     /* ✅ SIGNED OFF — do not change */
	--zeal-light-red: #fdf8f8;        /* bracketed: #fdf6f6 too solid · #fefafa too light */
	--zeal-light-blue: #fcfcfe;       /* ✅ SIGNED OFF — do not change */
	/* ── THE WAVE-EDITOR SECTION PAIR (Vince, 2026-08-19) ────────────────────────────────────────────────
	   "We want the header to be that nice, light neutral blue, and then the body to be a different color than
	   white, but not the neutral blue.  A much much lighter neutral blue."

	   TWO NAMED SHADES, because the tone rule above demands it: a tone must resolve to a NAMED colour and a
	   named colour must come from the palette, so a shade the palette lacks gets ADDED with a name rather than
	   typed into a rule. Both are the same hue as --zeal-blue (#2563eb), just far off in lightness:
	     · BAND is the header — visible blue, the tint the neutral section bands already read as.
	     · WASH is the body — barely there, but decidedly not white beside the band.
	   ⚠️ --zeal-light-blue is NOT either of them. It is the near-white section FILL and is signed off; these
	   sit alongside it rather than replacing it. */
	/* ⚠️ TUNED TWICE, AND THE SECOND LESSON IS THE ONE THAT STICKS (Vince, 2026-08-19). Round one read as
	   purple — red was riding too close to green (#e9effb, G−R of 6 renders lavender), so the spread was
	   widened. Round two fixed the hue but was "more solid… they need to be more see through, more faded, like
	   the neutral green header." The house tones are all NEAR-WHITE WASHES (the light palette above lives in
	   the #fa–#fc range), and a band in the #e6 range is a paint chip next to them however correct its hue. So:
	   same G−R spread that killed the purple, pulled way up toward white to match the family the rest of the
	   app's tinted surfaces belong to. */
	--zeal-blue-band: #f0f6fd;        /* the header band — faded, one clear step below white */
	--zeal-blue-wash: #fafcfe;        /* the body wash — a whisper off white, still bluer than the band is light */
	--zeal-light-neutral: #fcfcfc;    /* ✅ SIGNED OFF — do not change */
	--zeal-light-purple: #fdfcfe;     /* the near-white that goes with --zeal-purple — see --zt-select-bg */
	/* KEPT AS AN ALIAS so existing `--zeal-light-gold` references resolve to the one yellow rather than drifting
	   into a second one. New code should say yellow. */
	--zeal-light-gold: var(--zeal-light-yellow);

	/* ── THE DARK TONE SET — the ICON colour for each tone ───────────────────────────────────────────────
	   A toned card's title icon takes the DARK version of that tone, so the icon names the state at a glance
	   while the background stays a wash. Read off the legacy's compiled zeal-ui.css (.zt-action-item-* `color`,
	   which SASS produced from $zealDarkGreen / $zealDarkGold / $zealDarkRed / $zealDarkBlue). Same rule as the
	   light set: NEVER a hex at the call site — add a name here. */
	--zeal-dark-green: #152c15;       /* $zealDarkGreen */
	--zeal-dark-gold: #5b4800;        /* $zealDarkGold  */
	--zeal-dark-blue: #11204d;        /* $zealDarkBlue  */
	/* dark red + dark grey are already named above and are reused rather than declared twice. */

	/* ── THE STACK RHYTHM ─────────────────────────────────────────────────────────────────────────────────
	   ONE spacing value for a vertical list of item cards inside a section body — the gap BETWEEN items, and
	   deliberately the SAME figure as the flush action-card body's own padding (.zt-action-card-flush
	   .zt-action-card-body). That equality is the whole point: the space above the first card, between every
	   pair, and below the last are then identical, so a section reads as an evenly spaced stack instead of
	   three different gaps stacked on each other.

	   USE THIS, never a fresh literal, whenever a section stacks cards — that is what keeps Family, Programs
	   and everything after them on one rhythm. */
	--zt-stack-gap: 0.85rem;

	/* ── THE PAGE GUTTER ─────────────────────────────────────────────────────────────────────────────────
	   THE SPACE BETWEEN A PAGE SECTION AND THE SCREEN EDGE — the one value that makes every section look like
	   it is FLOATING over the Starfield rather than covering it (Vince, 2026-08-07: "this is a global change,
	   I don't wanna have to adjust this page to page to page").

	   ⚠️ IT IS PADDING ON THE PAGE CONTAINER (.zt-shell-page), NOT A MARGIN ON EACH CARD, and that distinction
	   is the whole fix. The per-card version had to name every section class — and there are dozens
	   (zt-programs-section, zt-cc-section, zt-member-search-card, zt-user-results …), so the ones nobody
	   remembered to list stayed full-bleed while their neighbours floated. That is exactly the "program
	   memberships aren't the same width as accounts" symptom. On the container it is ONE declaration and every
	   top-level section inherits it for free, forever, including sections that do not exist yet. Nested cards
	   are untouched, because they sit inside their section's own padding.

	   TO OPT OUT (a header that must bleed edge to edge), pull the element back out with
	   `margin-inline: calc(var(--zt-page-gutter) * -1); width: auto;` — see .zt-profile-header. */
	--zt-page-gutter: 16px;

	/* ── THE FOUR STATE TONES ────────────────────────────────────────────────────────────────────────────
	   happy green · warning gold · error red · neutral blue. These are SEMANTIC NAMES POINTING AT THE PALETTE
	   ABOVE — they are not colours of their own.

	   ⚠️ NEVER PUT A HEX HERE. These carried four hand-picked near-whites, which is how the app ended up with a
	   happy card and a happy header band that were each a shade off one another: two things claiming to be "the
	   happy colour" while naming different values. A tone must resolve to a NAMED colour, and a named colour must
	   come from the palette. If a tone needs a shade the palette does not have, the palette gets the shade —
	   with a name — and the tone points at it. (Vince, 2026-08-04: "we just can't keep making up colors.") */
	--zt-happy-bg: var(--zeal-light-green);  --zt-happy-bd: #e6f1e9;
	--zt-warn-bg: var(--zeal-light-yellow);  --zt-warn-bd: #f6f0db;
	--zt-neutral-bg: var(--zeal-light-blue); --zt-neutral-bd: #e2e9f5;
	--zt-error-bg: var(--zeal-light-red);    --zt-error-bd: #f2e0e0;
	/* GREY — the fifth tone: no state at all. The default flush band, and anything that is simply a panel. */
	--zt-plain-bg: var(--zeal-light-neutral);
	/* PURPLE — the SIXTH tone, and the only one that is not a status: "this is the SELECTED / current one".
	   The legacy production app colours the passkey enrolled on the device you are sitting at purple, and that
	   is a different idea from good / warning / error — which is why it gets its own name rather than borrowing
	   one. Same pale-wash + stronger-border construction as the other five. */
	--zt-select-bg: var(--zeal-light-purple); --zt-select-bd: #e7e0ef;
	/* THE GOLD ACCENT that goes WITH the warn tone. --zeal-gold (#ffcb04) is the BUTTON gold: on a near-white
	   panel it all but disappears, so anything that has to READ as gold on a pale surface — a warn icon, a warn
	   accent tab — uses this deeper shade. Same value the .ztdc-gold icons already use. */
	--zt-gold-accent: #d4a017;
	/* ── THE HOVER GLOW, ONE PER TONE ────────────────────────────────────────────────────────────────────
	   THE MOUSE-OVER LIFT EVERY ROW-CARD SHARES (Vince, 2026-08-16, on the Default-template row: "the only
	   difference is it doesn't have the mouse over effect that some of the other controls have. So if we could
	   add the mouse over … it would be absolutely perfect").

	   IT IS THE ROW'S OWN ACCENT AT LOW ALPHA, never a generic black shadow: a grey haze under a green row reads
	   as dirt, and the whole point of the tone system is that a row's state is the SAME colour wherever it shows
	   up. Alphas are dialled by eye per hue for equal weight — gold needs more because it is the lightest.

	   ⚠️ APPLIED ONLY INSIDE `@media (hover: hover)` (charter rule 7a). A touch device has no hover to leave, so
	   an unguarded :hover STICKS to the last row tapped and reads as a selection that cannot be cleared. */
	--zt-happy-glow: rgba(45, 96, 45, 0.18);
	--zt-warn-glow: rgba(212, 160, 23, 0.24);
	--zt-neutral-glow: rgba(37, 99, 235, 0.18);
	--zt-error-glow: rgba(151, 27, 30, 0.18);
	--zt-plain-glow: rgba(0, 0, 0, 0.13);
	--zt-select-glow: rgba(58, 24, 86, 0.20);
	/* THE LEADING-ICON DISC — the soft circle behind a card's big left icon (Vince, 2026-08-05: the bare glyphs
	   looked "naked"). A TRANSLUCENT BLACK rather than a named grey ON PURPOSE: these cards sit on tinted fills
	   (happy green, warn gold, error red), and a flat #eee disc goes muddy on every one of them. 6% black reads
	   as the same soft light-grey recess over all five tones and over plain white. */
	--zt-icon-disc: rgba(0, 0, 0, 0.06);

	/* THE COMPACT BUTTON HEIGHT — ZtIconButton and ZtSmallButton BOTH size off this, because they are the
	   standard pair in an action row (a help icon beside a labelled action) and must match exactly. Change it
	   here and both move together; never hard-code a height on either. */
	--zt-btn-compact-h: 30px;

	--zeal-field-max: 350px;
	--zeal-radius: 5px;
}

/* PHONES GET A SLIGHTLY TIGHTER GUTTER than a desktop — enough that the Starfield clearly runs past both edges
   and the sections read as floating, without spending screen width a phone cannot spare. It is still a REAL
   gap: 12px on a 375px screen is a bit over 3% a side, which is what Vince asked for after 1% (~4px) turned out
   to be too subtle to read as floating at all. */
@media (max-width: 599px) {
	:root { --zt-page-gutter: 12px; }
}

/* every Zeal UI component renders in the self-hosted UI font (modals/toasts live in the
   top layer, so set it explicitly rather than relying on inheritance) */
.zt-input-container,
.zt-input-checkbox,
.ztbtn,
dialog.zt-modal,
.zt-toast {
	font-family: var(--zt-font);
	/* NO -webkit-font-smoothing: on Windows it disables ClearType subpixel AA and renders FUZZIER
	   (worst on white-on-dark). Let each OS use its native rendering — that's what keeps small text crisp. */
}

/* ---- ICON GLYPHS + FIXED WIDTH (fa-fw) --------------------------------- */
.fi {
	font-family: "Font Awesome 6 Pro";
	font-weight: 900;
	font-style: normal;
	line-height: 1;
	display: inline-block;
	width: 1.25em;          /* fa-fw: uniform width so icons align */
	text-align: center;
}
.fi-circle-question::before { content: "\f059"; }
.fi-circle-xmark::before { content: "\f057"; }    /* MODAL / TOAST CLOSE */
.fi-bug::before { content: "\f188"; }             /* ERROR TOAST */
.fi-bell::before { content: "\f0f3"; }            /* NOTIFICATIONS / TOASTS */
.fi-circle-notch::before { content: "\f1ce"; }   /* BUSY SPINNER (used with .fi-spin) */
.fi-info::before { content: "\f05a"; }

/* BUSY/LOADING SPIN — add .fi-spin to any .fi to rotate it (revert by removing the class). */
.fi-spin { animation: zt-fi-spin 0.8s linear infinite; transform-origin: center center; }
@keyframes zt-fi-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .fi-spin { animation: none; } }
.fi-atom::before { content: "\f5d2"; }
.fi-check-circle::before { content: "\f058"; }
.fi-calendar::before { content: "\f133"; }
.fi-clock::before { content: "\f017"; }
.fi-hashtag::before { content: "\f292"; }
.fi-envelope::before { content: "\f0e0"; }
.fi-lock::before { content: "\f023"; }
.fi-phone::before { content: "\f095"; }
.fi-pen::before { content: "\f304"; }
.fi-robot::before { content: "\f544"; }
.fi-eye::before { content: "\f06e"; }
.fi-eye-slash::before { content: "\f070"; }
.fi-globe::before { content: "\f0ac"; }
.fi-location-dot::before { content: "\f3c5"; }
.fi-map::before { content: "\f279"; }
.fi-road::before { content: "\f018"; }
.fi-house::before { content: "\f015"; }
.fi-tag::before { content: "\f02b"; }
.fi-list::before { content: "\f03a"; }
.fi-mailbox::before { content: "\f813"; }
.fi-triangle-exclamation::before { content: "\f071"; }
.fi-arrow-rotate-left::before { content: "\f0e2"; }
.fi-user::before { content: "\f007"; }
.fi-magnifying-glass::before { content: "\f002"; }
.fi-angle-down::before { content: "\f107"; }

/* ====================================================================== */
/* FA 7 PRO ICON SYSTEM (ZtIcon) — self-hosted from the RCL.               */
/* Glyph is set per-icon via the --zt-glyph CSS var (codepoint from the    */
/* generated FaIcons enum); the family class swaps which webfont renders.  */
/* No giant all.css, no per-icon CSS rules.                                */
/* ====================================================================== */
@font-face { font-family: "FA7 Solid";   font-weight: 900; font-style: normal; font-display: block; src: url("../fonts/fontawesome/fa-solid-900.woff2")   format("woff2"); }
@font-face { font-family: "FA7 Regular"; font-weight: 400; font-style: normal; font-display: block; src: url("../fonts/fontawesome/fa-regular-400.woff2") format("woff2"); }
@font-face { font-family: "FA7 Light";   font-weight: 300; font-style: normal; font-display: block; src: url("../fonts/fontawesome/fa-light-300.woff2")   format("woff2"); }
@font-face { font-family: "FA7 Brands";  font-weight: 400; font-style: normal; font-display: block; src: url("../fonts/fontawesome/fa-brands-400.woff2")  format("woff2"); }
@font-face { font-family: "FA7 Duotone"; font-weight: 900; font-style: normal; font-display: block; src: url("../fonts/fontawesome/fa-duotone-900.woff2") format("woff2"); }
/* THE KIT FONT — the custom Zeal glyphs uploaded to our Font Awesome kit (zeal, zeal-logo, zeal-logo-circle,
   zeal-logo-rectangle), served from the kit's custom-icons.woff2 exactly as the legacy webfonts copy. */
@font-face { font-family: "FA7 Kit";     font-weight: 400; font-style: normal; font-display: block; src: url("../fonts/fontawesome/custom-icons.woff2") format("woff2"); }

.zt-fa {
	font-family: "FA7 Solid";
	font-weight: 900;
	font-style: normal;
	font-variant: normal;
	line-height: 1;
	display: inline-block;
	text-rendering: auto;
}
.zt-fa::before { content: var(--zt-glyph, ""); }
.zt-fa.zt-fa-regular { font-family: "FA7 Regular"; font-weight: 400; }
.zt-fa.zt-fa-light   { font-family: "FA7 Light";   font-weight: 300; }
.zt-fa.zt-fa-brand   { font-family: "FA7 Brands";  font-weight: 400; }
/* DUOTONE — mirrors the OFFICIAL Font Awesome mechanism exactly (Zeal.Blazor.Web all.css). The SECONDARY layer
   (::after) sits in NORMAL FLOW and defines the element box; its content is the codepoint DOUBLED (--zt-glyph-2),
   which the duotone font's GSUB collapses into the single secondary-layer glyph at 0.4 opacity. The PRIMARY layer
   (::before) is ABSOLUTELY positioned ON TOP at full opacity, sharing the same origin so the layers register
   perfectly (fixes the old misalignment where the two layers drifted apart). */
/* KIT (custom Zeal glyphs) — single-layer, weight 400, exactly like the legacy .fak/.fa-kit classes. ZtIcon
   picks this family for kit icons regardless of the requested style (the kit font has no duotone layers). */
.zt-fa.zt-fa-kit { font-family: "FA7 Kit"; font-weight: 400; }
.zt-fa.zt-fa-duotone { font-family: "FA7 Duotone"; font-weight: 900; position: relative; }
.zt-fa.zt-fa-duotone::before {
	content: var(--zt-glyph, "");
	/* ONLY position:absolute (no offsets) — exactly like FA's all.css. The auto static position keeps the primary
	   layer on the SAME baseline as the in-flow ::after so the two layers register; any left/top pins it to the box
	   corner instead and the layers drift apart (the "head falling off" bug). fa-fw centering handles horizontal. */
	position: absolute;
	opacity: var(--zt-fa-primary-opacity, 1);
}
.zt-fa.zt-fa-duotone::after {
	content: var(--zt-glyph-2, "");
	opacity: var(--zt-fa-secondary-opacity, 0.4);
}
/* fixed width so columns of icons line up (fa-fw) */
.zt-fa.zt-fa-fw { width: 1.25em; text-align: center; }
/* spin (busy) */
.zt-fa.zt-fa-spin { animation: zt-fi-spin 0.8s linear infinite; transform-origin: center center; }
@media (prefers-reduced-motion: reduce) { .zt-fa.zt-fa-spin { animation: none; } }

/* ---- FA CLASS COMPAT — SITE SECTION CONTENT ONLY ------------------------ */
/* Owner section HTML (the create-site template + legacy-authored content) carries REAL Font Awesome
   classes (<span class="fad fa-city fa-fw">), which the app never styles: ZtIcon renders by codepoint
   and no all.css is shipped, so those spans paint NOTHING on the Maui canvases. These rules give the
   SITE CANVAS (public tenant page, editor, RTE surface — all wear .layout-container) the same duotone
   mechanism as .zt-fa for exactly the icon classes the template emits. Codepoints verified against the
   legacy all.min.css; the secondary layer is the codepoint DOUBLED (the duotone font's GSUB trick). */
.layout-container .fad {
	font-family: "FA7 Duotone";
	font-weight: 900;
	font-style: normal;
	font-variant: normal;
	line-height: 1;
	display: inline-block;
	text-rendering: auto;
	position: relative;
}
.layout-container .fad::before { content: var(--fa-glyph, ""); position: absolute; opacity: 1; }
.layout-container .fad::after  { content: var(--fa-glyph-2, ""); opacity: 0.4; }
.layout-container .fa-fw { width: 1.25em; text-align: center; }
.layout-container .fa-location-dot    { --fa-glyph: "\f3c5"; --fa-glyph-2: "\f3c5\f3c5"; }
.layout-container .fa-city            { --fa-glyph: "\f64f"; --fa-glyph-2: "\f64f\f64f"; }
.layout-container .fa-building        { --fa-glyph: "\f1ad"; --fa-glyph-2: "\f1ad\f1ad"; }
.layout-container .fa-circle-phone    { --fa-glyph: "\e11b"; --fa-glyph-2: "\e11b\e11b"; }
.layout-container .fa-circle-envelope { --fa-glyph: "\e10c"; --fa-glyph-2: "\e10c\e10c"; }
.layout-container .fa-star-sharp      { --fa-glyph: "\e28b"; --fa-glyph-2: "\e28b\e28b"; }
.layout-container .fa-people-group    { --fa-glyph: "\e533"; --fa-glyph-2: "\e533\e533"; }
.layout-container .fa-badge-dollar    { --fa-glyph: "\f645"; --fa-glyph-2: "\f645\f645"; }
/* THE EVENT FLYER'S FACT-ROW ICONS (BuildEventFlyerAsync) — SAME CONVENTION, SAME VERIFICATION */
.layout-container .fa-calendar-check  { --fa-glyph: "\f274"; --fa-glyph-2: "\f274\f274"; }
.layout-container .fa-calendar-xmark  { --fa-glyph: "\f273"; --fa-glyph-2: "\f273\f273"; }
.layout-container .fa-calendar-clock  { --fa-glyph: "\e0d2"; --fa-glyph-2: "\e0d2\e0d2"; }
.layout-container .fa-calendar-star   { --fa-glyph: "\f736"; --fa-glyph-2: "\f736\f736"; }
.layout-container .fa-users           { --fa-glyph: "\f0c0"; --fa-glyph-2: "\f0c0\f0c0"; }
.layout-container .fa-id-card         { --fa-glyph: "\f2c2"; --fa-glyph-2: "\f2c2\f2c2"; }
.layout-container .fa-child-reaching  { --fa-glyph: "\e59d"; --fa-glyph-2: "\e59d\e59d"; }

/* ---- CONTAINER --------------------------------------------------------- */
.zt-input-container {
	margin: 0.75em 0 0.5em;
	width: 100%;
	max-width: var(--zeal-field-max);
}

/* ---- CONTROL STATE: Hide() / Busy() (Show()/NotBusy() = default) -------- */
/* HIDE — collapse the whole control out of layout */
.zt-input-container.zt-hidden,
.zt-input-checkbox.zt-hidden { display: none; }

/* SAME MEANING ON A BUTTON — used by the login page's passkey button, which is rendered hidden and revealed by
   zt-passkey.js only once the device reports a usable authenticator. Server-side rendering cannot ask the browser
   that question, and a passkey button that leads nowhere is worse than no button at all. */
.ztbtn.zt-hidden { display: none; }

/* BUSY — input goes read-only (inert attr on the control, no JS), greyed, icons spin */
.zt-input-container.zt-busy .zt-input-field { background: #f4f4f4; cursor: progress; }
.zt-input-container.zt-busy .zt-input-field-control { opacity: 0.5; }
/* swap BOTH the big field icon and the small header icon to a spinning notch */
.zt-input-container.zt-busy .zt-input-field-icon .zt-fa,
.zt-input-container.zt-busy .zt-input-icon .zt-fa { visibility: hidden; }
.zt-input-container.zt-busy .zt-input-field-icon,
.zt-input-container.zt-busy .zt-input-icon { position: relative; }
.zt-input-container.zt-busy .zt-input-field-icon::after,
.zt-input-container.zt-busy .zt-input-icon::after {
	content: "\f1ce";                                  /* fa circle-notch */
	font-family: "FA7 Solid";
	font-weight: 900;
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--zeal-tiffany);
	animation: zt-fi-spin 0.8s linear infinite;
}
.zt-input-container.zt-busy .zt-input-icon::after { font-size: 12px; color: inherit; }
/* checkbox busy */
.zt-input-checkbox.zt-busy { opacity: 0.55; }
.zt-input-checkbox.zt-busy .zt-input-checkbox-row { cursor: progress; }

.zt-input {
	padding: 0;
	border: 0.5pt solid var(--zeal-medium-grey);
	border-radius: var(--zeal-radius);
	width: 100%;
	font-size: 0.8rem;
	overflow: hidden;
}

/* ---- HEADER BAR -------------------------------------------------------- */
.zt-input-header {
	display: flex;
	align-items: center;
	gap: 0.5em;
	background-color: var(--zeal-header-grey);
	color: var(--zeal-white);
	width: 100%;
	padding: 0.3em 0.4em 0.3em 0.6em;
	border-bottom: 1pt solid var(--zeal-grey);
}

.zt-input-icon { flex-shrink: 0; display: flex; align-items: center; }
.zt-input-icon .zt-fa { font-size: 12px; }

.zt-input-label { flex: 1 1 auto; min-width: 0; }
.zt-input-label label {
	margin: 0;
	font-weight: 600;
	font-size: 11px;
	letter-spacing: 0.02em;
	text-transform: uppercase;
	cursor: pointer;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	display: block;
}
.zt-input-label label::after { content: ":"; }

/* HELP "?" — pushed right ONLY inside the header bar */
.zt-input-help-icon { flex-shrink: 0; display: flex; align-items: center; }
.zt-input-header .zt-input-help-icon { margin-left: auto; }
.zt-input-help-icon button {
	background: none;
	border: none;
	padding: 0;
	cursor: pointer;
	color: var(--zeal-gold);
	font-size: 15px;
	line-height: 1;
	display: flex;
}
.zt-input-help-icon button:hover { color: #ffd84d; }

/* ---- FIELD ROW --------------------------------------------------------- */
.zt-input-field {
	display: flex;
	align-items: center;
	gap: 0.4em;
	padding: 0.5em 0.6em;
	background: var(--zeal-white);
	/* UNIFORM ROW HEIGHT — every single-line control (text/date/select/number/email/phone/…) lands on the SAME
	   field height so fields line up perfectly in rows/columns. The textarea (multi-line) overrides via min-height
	   and simply grows past this. This is the library-wide fix for the row-misalignment Vince flagged. */
	min-height: var(--zt-control-height, 2.6rem);
	box-sizing: border-box;
}

.zt-input-field-icon {
	flex-shrink: 0;
	font-size: 165%;
	color: var(--zeal-light-grey);
	display: flex;
	align-items: center;
}

.zt-input-field-control { flex: 1 1 auto; min-width: 0; }

.zt-input-field-control input,
.zt-input-field-control select,
.zt-input-field-control textarea {
	border: none;
	outline: none;
	width: 100%;
	font-family: inherit;
	font-size: 1.05rem;
	color: var(--zeal-dark-grey);
	background: transparent;
	padding: 0;
	margin: 0;
}
/* SINGLE-LINE controls share ONE explicit line-box height so a native <select> and a text <input> render at the
   EXACT same height (native selects otherwise size themselves differently). Kept below the field row's min-height
   so the row height stays uniform. Textarea is excluded (multi-line). */
.zt-input-field-control input,
.zt-input-field-control select {
	height: 1.5em;
	line-height: 1.5em;
	box-sizing: content-box;
}
.zt-input-field-control select { cursor: pointer; }
.zt-input-field-control textarea { resize: vertical; min-height: 80px; }

.zt-input-field-control input::placeholder,
.zt-input-field-control textarea::placeholder { color: var(--zeal-light-grey); font-size: 90%; opacity: 1; }

/* ── DATE INPUT: trailing calendar button + full-screen picker ─────────────────────────────────────────── */
.zt-date-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.zt-date-input-row input { flex: 1 1 auto; width: auto; min-width: 0; }
.zt-date-pick-btn {
	flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
	background: transparent; border: none; cursor: pointer; color: var(--zeal-tiffany);
	font-size: 1.05rem; padding: 2px 4px; border-radius: 5px; line-height: 1;
}
.zt-date-pick-btn:hover { color: var(--zeal-green); background: rgba(0,0,0,0.04); }

/* full-screen picker overlay — sits above app chrome; taps switch day / month / year. */
/* Default bottom-nav height so full-screen pickers center above the bar even on pages whose navbar doesn't
   publish a live height (the Static-SSR demo navbar). The interactive ZtNavBar overrides this precisely (and
   grows it when it wraps to two rows). */
:root { --zt-navbar-h: 60px; }
.zt-datepicker {
	/* Pinned to the EXACT visible rectangle measured by JS (visualViewport → --vv-top / --vv-height). This covers
	   only the visible band (backdrop over all visible page content, no gap) AND lets align-items:center land the
	   panel in the true center — no vh/dvh guessing. Falls back to dvh/vh when the vars aren't set. */
	position: fixed; left: 0; right: 0; z-index: 2147483000; box-sizing: border-box;
	top: var(--vv-top, 0px);
	height: 100vh;
	height: 100dvh;
	height: var(--vv-height, 100dvh);
	background: rgba(0, 0, 0, 0.55); display: flex; align-items: center; justify-content: center;
	/* reserve the app nav bar (live height, grows for a 2-row bar) so centering is ABOVE it. */
	padding: 0.75rem 0.75rem calc(0.75rem + var(--zt-navbar-h, 60px));
}
/* lock the page while a full-screen picker is open (no scrolling the content behind the wheel). */
body.zt-noscroll { overflow: hidden; }
.zt-datepicker-panel {
	background: var(--zeal-white); border-radius: 12px; width: min(540px, 94vw); box-sizing: border-box;
	/* HARD cap: an absolute ceiling (so it's never floor-to-ceiling on tall screens) AND a viewport bound that
	   stays SHORTER than the visible area (dvh minus the live navbar height — which grows for a 2-row bar — minus
	   4rem) so `align-items:center` always leaves EQUAL breathing room above and below, on any screen. (A flex %
	   max-height doesn't resolve reliably, hence the viewport math.) The list scrolls inside. */
	max-height: min(38rem, calc(100vh - 60px - 4rem));
	max-height: min(38rem, calc(var(--vv-height, 100dvh) - var(--zt-navbar-h, 60px) - 4rem));
	overflow: auto;
	box-shadow: 0 12px 44px rgba(0, 0, 0, 0.45); padding: 0.85rem 1rem 1.05rem; display: flex; flex-direction: column;
}
.zt-datepicker-head { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.6rem; }
/* Picker header title (icon + text) is LEFT-aligned; its flex:1 still pushes the close button to the right. The
   DATE picker keeps its month/year title CENTERED because it sits BETWEEN the prev/next arrows — detected by the
   presence of a nav button in the head. Global to every picker (dropdown / time / date). */
.zt-datepicker-title { flex: 1 1 auto; display: flex; align-items: center; justify-content: flex-start; gap: 0.5rem; }
.zt-datepicker-head:has(.zt-datepicker-nav) .zt-datepicker-title { justify-content: center; }
.zt-datepicker-title-btn {
	background: transparent; border: none; cursor: pointer; font-size: 1.2rem; font-weight: 700;
	color: var(--zeal-dark-grey); padding: 0.2rem 0.5rem; border-radius: 6px;
}
.zt-datepicker-title-btn:hover { background: #f0f0f0; color: var(--zeal-tiffany); }
.zt-datepicker-nav, .zt-datepicker-close {
	background: transparent; border: none; cursor: pointer; font-size: 1.15rem; color: var(--zeal-grey);
	padding: 0.35rem 0.5rem; border-radius: 6px; display: inline-flex; align-items: center;
}
.zt-datepicker-nav:hover { background: #f0f0f0; color: var(--zeal-tiffany); }
/* CLOSE is neutral (grey), not red — red reads as delete/destructive; this just dismisses the calendar. */
.zt-datepicker-close { color: var(--zeal-grey); }
.zt-datepicker-close:hover { background: #f0f0f0; color: var(--zeal-dark-grey); }
.zt-datepicker-weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-bottom: 6px; }
.zt-datepicker-weekdays span { text-align: center; font-weight: 700; font-size: 0.82rem; color: var(--zeal-grey); }
.zt-datepicker-grid { display: grid; gap: 6px; }
.zt-datepicker-days { grid-template-columns: repeat(7, 1fr); }
.zt-datepicker-months, .zt-datepicker-years { grid-template-columns: repeat(3, 1fr); }
.zt-datepicker-day, .zt-datepicker-cell {
	border: 2px solid transparent; background: #f5f6f6; border-radius: 8px; cursor: pointer;
	font-size: 1rem; font-weight: 600; color: var(--zeal-dark-grey);
	display: flex; align-items: center; justify-content: center;
}
/* COMPACT fixed-height cells (was aspect-ratio:1/1 → square cells made the calendar ~7 rows tall and it couldn't
   fit/center on phones). A capped height keeps the whole calendar short enough to center. */
.zt-datepicker-day { min-height: 2.5rem; }
.zt-datepicker-months .zt-datepicker-cell, .zt-datepicker-years .zt-datepicker-cell { padding: 0.9rem 0.4rem; }
.zt-datepicker-day:hover, .zt-datepicker-cell:hover { background: var(--zeal-tiffany); color: var(--zeal-white); }
.zt-datepicker-day.is-outside { color: var(--zeal-light-grey); background: transparent; }
.zt-datepicker-day.is-today { border-color: var(--zeal-tiffany); }
.zt-datepicker-day.is-selected, .zt-datepicker-cell.is-selected {
	background: var(--zeal-green); color: var(--zeal-white); border-color: var(--zeal-green);
}

/* time picker — hour / minute / am-pm columns + a big readout. */
.zt-timepicker-panel { width: min(480px, 94vw); }
.zt-timepicker-readout { font-size: 1.4rem; font-weight: 700; color: var(--zeal-tiffany); }
.zt-timepicker-cols { display: grid; grid-template-columns: 2fr 2fr 1fr; gap: 0.9rem; }
.zt-timepicker-col { display: flex; flex-direction: column; }
.zt-timepicker-heading { text-align: center; font-weight: 700; font-size: 0.85rem; color: var(--zeal-grey); margin-bottom: 6px; }
.zt-timepicker-hours, .zt-timepicker-minutes { grid-template-columns: repeat(3, 1fr); }
.zt-timepicker-hours .zt-datepicker-cell, .zt-timepicker-minutes .zt-datepicker-cell { padding: 0.85rem 0; }
.zt-timepicker-ampm { gap: 6px; }
.zt-timepicker-ampm .zt-datepicker-cell { padding: 1.1rem 0; }
.zt-timepicker-actions { display: flex; justify-content: flex-start; margin-top: 1rem; }

/* ── DROPDOWN: upgraded trigger + full-screen picker ───────────────────────────────────────────────────── */
.zt-dropdown-trigger {
	display: flex; align-items: center; justify-content: space-between; gap: 8px;
	width: 100%; height: 1.5em; background: transparent; border: none; cursor: pointer; padding: 0;
	font-family: inherit; font-size: 1.05rem; color: var(--zeal-dark-grey); text-align: left;
}
.zt-dropdown-trigger-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-dropdown-trigger-text.is-placeholder { color: var(--zeal-light-grey); }
.zt-dropdown-trigger-chevron { color: var(--zeal-grey); flex: 0 0 auto; font-size: 0.85rem; }

/* The panel WRAPS its content (head + filter + a list sized to the item count), so it stays short and the base
   overlay's align-items:center centers it reliably on any screen — same as the calendar. Row/gap are shared vars;
   the window's height is driven by --zt-picker-count (min(itemCount, N)) set inline → SHORT lists hug their items
   (no dead space), LONG lists cap at N rows and become a scroll wheel. */
.zt-dropdown-picker-panel {
	--zt-picker-row: 2.9rem;
	--zt-picker-gap: 8px;
	width: min(520px, 94vw); overflow: hidden;
}
/* tighten the title → filter gap a touch. */
.zt-dropdown-picker .zt-datepicker-head { margin-bottom: 0.5rem; }
.zt-dropdown-picker-title { font-size: 1.2rem; font-weight: 700; color: var(--zeal-dark-grey); }
.zt-dropdown-picker-title-icon { color: var(--zeal-tiffany); font-size: 1.25rem; display: inline-flex; align-items: center; }
.zt-dropdown-picker-search {
	display: flex; align-items: center; gap: 8px; padding: 0.55rem 0.8rem; margin-bottom: 0.7rem;
	background: #f4f5f5; border-radius: 10px; color: var(--zeal-grey);
}
.zt-dropdown-picker-search input { flex: 1 1 auto; border: none; outline: none; background: transparent; font-size: 1.05rem; color: var(--zeal-dark-grey); }
.zt-dropdown-picker-clear { background: transparent; border: none; cursor: pointer; color: var(--zeal-grey); display: inline-flex; }
.zt-dropdown-picker-listwrap { position: relative; flex: 0 0 auto; }
/* WHEEL payline — a fixed one-row frame LOCKED to the vertical center of the window. Items snap under it, so the
   row it frames is always the centered (scale-1) one — slot-machine style. pointer-events:none so it never eats a
   tap; z-index above the rows so its outline hugs the framed row from on top. */
.zt-dropdown-picker-band {
	position: absolute; left: 4px; right: 4px; top: 50%; transform: translateY(-50%);
	height: var(--zt-picker-row); border: 2px solid var(--zeal-tiffany); border-radius: 11px;
	pointer-events: none; z-index: 5;
}
.zt-dropdown-picker-list {
	flex: 0 0 auto;
	/* height = exactly N rows worth of item + gap. --zt-picker-count is min(itemCount, VisibleRows). */
	height: calc(var(--zt-picker-count, 5) * var(--zt-picker-row) + (var(--zt-picker-count, 5) - 1) * var(--zt-picker-gap));
	overflow-y: auto;
	display: flex; flex-direction: column; gap: var(--zt-picker-gap); padding: 0 4px;
	overscroll-behavior: contain;              /* don't chain the scroll to the page behind */
	-webkit-overflow-scrolling: touch;         /* iOS momentum — flick and it glides */
	scrollbar-width: none;                     /* hide the scrollbar → clean wheel look */
}
.zt-dropdown-picker-list::-webkit-scrollbar { display: none; }
/* WHEEL mode only: snap to a row, and the centered row (marked by JS on its OWN box) gets the tiffany outline that
   HUGS it exactly (over any hover/selected fill). SHORT lists are a plain tap-list — no snap, no center outline. */
.zt-dropdown-picker-panel.is-wheel .zt-dropdown-picker-list { scroll-snap-type: y mandatory; }
.zt-dropdown-picker-panel.is-wheel .zt-dropdown-picker-item { scroll-snap-align: center; }
.zt-dropdown-picker-item {
	display: flex; align-items: center; justify-content: center; gap: 11px; width: 100%; box-sizing: border-box;
	height: var(--zt-picker-row); text-align: center; padding: 0 2.6rem; position: relative;
	border-radius: 11px; border: 2px solid transparent; background: #f4f5f5;
	cursor: pointer; font-size: 1.05rem; font-weight: 600; color: var(--zeal-dark-grey);
}
.zt-dropdown-picker-item:hover { background: var(--zeal-tiffany); color: var(--zeal-white); }
.zt-dropdown-picker-item.is-selected { border-color: var(--zeal-green); background: var(--zeal-green); color: var(--zeal-white); }
.zt-dropdown-picker-item-icon { flex: 0 0 auto; color: var(--zeal-tiffany); font-size: 1.15rem; }
.zt-dropdown-picker-item:hover .zt-dropdown-picker-item-icon,
.zt-dropdown-picker-item.is-selected .zt-dropdown-picker-item-icon { color: inherit; }
/* CENTERED item content (icon + text) — the iOS-wheel look; the check floats to the right WITHOUT shifting the
   centered label (was flex:1 filling left, pushing everything left with a big empty gap). Global to every picker. */
.zt-dropdown-picker-item-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-dropdown-picker-item-check { position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); }
.zt-dropdown-picker-empty { text-align: center; color: var(--zeal-grey); padding: 1.6rem; }

/* iOS-wheel curve — WHEEL mode rows scale + fade toward the window's top/bottom edges (only where scroll-driven
   animations are supported). Scoped to .is-wheel so SHORT lists stay full-size and fully opaque. */
@supports (animation-timeline: view()) {
	.zt-dropdown-picker-panel.is-wheel .zt-dropdown-picker-item {
		animation: zt-wheel linear both;
		animation-timeline: view(block);
		animation-range: cover 0% cover 100%;
	}
	@keyframes zt-wheel {
		0% { opacity: 0.4; transform: scale(0.86); }
		50% { opacity: 1; transform: scale(1); }
		100% { opacity: 0.4; transform: scale(0.86); }
	}
}
/* the whole panel rises/fades in on open. */
.zt-dropdown-picker .zt-datepicker-panel { animation: zt-picker-in 0.22s ease both; }
@keyframes zt-picker-in { from { opacity: 0; transform: translateY(14px) scale(0.985); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
	.zt-dropdown-picker .zt-datepicker-panel { animation: none; }
	@supports (animation-timeline: view()) { .zt-dropdown-picker-item { animation: none; opacity: 1; transform: none; } }
}

/* REQUIRED + EMPTY = ORANGE FIELD ICON (state-driven; see VALIDATION STATE below) */
.zt-input.required .zt-input-field-icon { color: var(--zeal-orange); }

/* PASSWORD SHOW/HIDE (inline, no JS) */
.zt-input-pw { display: flex; align-items: center; gap: 8px; }
.zt-input-pw input { flex: 1 1 auto; }
.zt-input-pw-toggle {
	flex-shrink: 0;
	align-self: center;          /* don't let the icon stretch the row height */
	width: 1.6em;
	height: 1.6em;               /* FIXED box — swapping eye/eye-slash can't change the control height */
	line-height: 1;
	overflow: hidden;
	background: none;
	border: none;
	padding: 0;
	cursor: pointer;
	color: #888;
	font-size: 1.15rem;
	display: flex;
	align-items: center;
	justify-content: center;
}
.zt-input-pw-toggle:hover { color: var(--zeal-orange); }
/* Both eye icons forced to the SAME centered box; vanilla JS toggles .showing to swap which one shows
   (cross-platform, no circuit). Identical boxes = no vertical bounce on toggle. */
.zt-input-pw-toggle .zt-pw-eye,
.zt-input-pw-toggle .zt-pw-eye-off { align-items: center; justify-content: center; line-height: 1; }
.zt-input-pw-toggle .zt-pw-eye { display: flex; }
.zt-input-pw-toggle .zt-pw-eye-off { display: none; }
.zt-input-pw-toggle.showing .zt-pw-eye { display: none; }
.zt-input-pw-toggle.showing .zt-pw-eye-off { display: flex; }

/* PHONE — country dropdown + number (kept tight) */
.zt-phone { display: flex; align-items: center; gap: 4px; width: 100%; }
.zt-input-field-control .zt-phone-country {
	flex: 0 0 auto;
	width: 86px;
	border: none;
	outline: none;
	background: transparent;
	font-size: 0.98rem;
	color: var(--zeal-dark-grey);
	cursor: pointer;
	padding: 0;
}
.zt-phone-divider { flex: 0 0 auto; color: var(--zeal-light-grey); margin: 0 1px; }
.zt-phone-number { flex: 1 1 auto; min-width: 0; }

/* SEARCHABLE SELECT (ZtSelect) */
.zt-select { position: relative; width: 100%; }
.zt-select-input { width: 100%; border: none; outline: none; background: transparent; font-size: 1.05rem; color: var(--zeal-dark-grey); padding: 0; }
.zt-select-input::-webkit-search-cancel-button,
.zt-select-input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.zt-input:has(.zt-select-results) { overflow: visible; }  /* let the results escape the field box */
.zt-select-results {
	position: absolute;
	top: calc(100% + 8px);
	left: -4px;
	right: -4px;
	z-index: 60;
	background: #fff;
	border: 1px solid #d6d6d6;
	border-radius: 8px;
	max-height: 290px;
	overflow-y: auto;
	box-shadow: 0 14px 36px rgba(0, 0, 0, .24);
	padding: 5px;
}
/* little caret so it clearly belongs to the field above */
.zt-select-results::before {
	content: "";
	position: absolute;
	top: -7px;
	left: 18px;
	width: 12px;
	height: 12px;
	background: #fff;
	border-left: 1px solid #d6d6d6;
	border-top: 1px solid #d6d6d6;
	transform: rotate(45deg);
}
.zt-select-item {
	display: flex;
	align-items: center;
	gap: 11px;
	padding: 9px 11px;
	cursor: pointer;
	border-radius: 6px;
}
.zt-select-item:hover { background: #f4f4f4; }
.zt-select-item-icon { flex: 0 0 auto; color: var(--zeal-light-grey); font-size: 1.15rem; }
.zt-select-item:hover .zt-select-item-icon { color: var(--zeal-orange); }
.zt-select-item-body { min-width: 0; }
.zt-select-item-text { display: block; font-weight: 600; color: var(--zeal-dark-grey); font-size: 0.98rem; line-height: 1.2; }
.zt-select-item-sub { display: block; font-size: 0.8rem; color: #8a8a8a; line-height: 1.15; margin-top: -2px; }
.zt-select-empty { padding: 10px 11px; color: #999; font-size: 0.9rem; }

/* COUNTRY PICKER (ZtCountrySelect) — custom dropdown with consistent flag images */
.zt-cselect { position: relative; width: 100%; }
.zt-cselect-trigger {
	display: flex; align-items: center; gap: 9px; width: 100%;
	background: transparent; border: none; cursor: pointer; padding: 0;
	font-size: 1.05rem; color: var(--zeal-dark-grey); text-align: left;
}
.zt-cselect-flag {
	width: 24px; height: 18px; object-fit: cover; border-radius: 2px;
	flex: 0 0 auto; box-shadow: 0 0 0 1px rgba(0, 0, 0, .12);
}
.zt-cselect-text { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-cselect-placeholder { color: var(--zeal-light-grey); }
.zt-cselect-caret { flex: 0 0 auto; color: #999; font-size: 0.95rem; }
.zt-input:has(.zt-cselect-panel) { overflow: visible; }
.zt-cselect-backdrop { position: fixed; inset: 0; z-index: 59; }
.zt-cselect-panel {
	position: absolute; top: calc(100% + 8px); left: -4px; right: -4px; z-index: 60;
	background: #fff; border: 1px solid #d6d6d6; border-radius: 8px;
	box-shadow: 0 14px 36px rgba(0, 0, 0, .24); overflow: hidden;
}
.zt-cselect-search { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-bottom: 1px solid #eee; color: #999; }
.zt-cselect-searchbox { flex: 1 1 auto; border: none; outline: none; background: transparent; font-size: 0.95rem; color: var(--zeal-dark-grey); }
.zt-cselect-searchbox::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.zt-cselect-list { max-height: 260px; overflow-y: auto; padding: 4px; }
.zt-cselect-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; cursor: pointer; }
.zt-cselect-item:hover { background: #f4f4f4; }
.zt-cselect-item.selected { background: var(--zt-happy-bg); }
.zt-cselect-item-text { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; color: var(--zeal-dark-grey); font-size: 0.95rem; }
.zt-cselect-item-dial { flex: 0 0 auto; color: #999; font-size: 0.85rem; }
.zt-cselect-empty { padding: 12px; color: #999; }

/* Neutralize the Blazor template's global `.invalid`/`.valid.modified` red/green OUTLINE
   (app.css) — our containers carry those class names but we style states ourselves. */
.zt-input.invalid, .zt-input.valid, .zt-input.required, .zt-input-checkbox.invalid { outline: none; }

/* ---- VALIDATION STATE (header + field tint) ---------------------------- */
.zt-input.valid .zt-input-header { background-color: var(--zeal-green); border-bottom-color: #214521; }
.zt-input.valid .zt-input-field { background-color: var(--zeal-light-green); }
.zt-input.valid .zt-input-field-icon { color: var(--zeal-green); }

.zt-input.invalid .zt-input-header {
	background-color: var(--zeal-red);
	background-image: linear-gradient(326deg, var(--zeal-dark-red) 0%, var(--zeal-red) 74%);
	border-bottom-color: var(--zeal-dark-red);
}
.zt-input.invalid .zt-input-field { background-color: var(--zeal-light-red); }
.zt-input.invalid .zt-input-field-icon { color: var(--zeal-red); }

/* HIDE THE BIG FIELD ICON FOR TEXTAREAS (matches legacy) */
.zt-input-container:has(textarea) .zt-input-field-icon { display: none; }

/* ---- VALIDATION MESSAGE ------------------------------------------------ */
.zt-validation-message-container { width: 100%; max-width: var(--zeal-field-max); }
.validation-message {
	display: block;
	font-size: 9pt;
	font-weight: 700;
	color: var(--zeal-dark-red);
	text-transform: uppercase;
	padding-left: 1px;
	margin-top: 2px;
}

/* ---- CHECKBOX ---------------------------------------------------------- */
.zt-input-checkbox { margin: 0.75em 0 0.5em; max-width: var(--zeal-field-max); }
.zt-input-checkbox-row { display: flex; align-items: center; }
/* custom-rendered box so unchecked states can be cleanly coloured (native accent-color only
   shows when checked). Unchecked = grey box, checked = GREEN box + white check, invalid = RED box. */
.zt-input-checkbox input[type="checkbox"] {
	appearance: none;
	-webkit-appearance: none;
	width: 19px;
	height: 19px;
	border: 2px solid var(--zeal-light-grey);
	border-radius: 3px;
	background: #fff;
	cursor: pointer;
	flex: 0 0 auto;
	margin: 0 8px 0 0;
	position: relative;
	transition: background .12s ease, border-color .12s ease;
}
.zt-input-checkbox input[type="checkbox"]:checked {
	background: var(--zeal-green);
	border-color: var(--zeal-green);
}
.zt-input-checkbox input[type="checkbox"]:checked::after {
	content: "";
	position: absolute;
	left: 5px;
	top: 1px;
	width: 5px;
	height: 10px;
	border: solid #fff;
	border-width: 0 2px 2px 0;
	transform: rotate(45deg);
}
.zt-input-checkbox .zt-input-help-icon { margin-left: 3px; }
/* INVALID (required + unchecked + failed) = empty RED box */
.zt-input-checkbox.invalid input[type="checkbox"] { border-color: var(--zeal-red); background: #fff; }
.zt-input-checkbox label {
	margin: 0;
	font-weight: 600;
	font-size: 0.95rem;
	color: var(--zeal-dark-grey);
	cursor: pointer;
}
/* checkbox help sits RIGHT AFTER the text (no margin-left:auto) */
.zt-input-checkbox .zt-input-help-icon button { color: var(--zeal-green); }
.zt-input-checkbox .zt-input-help-icon button:hover { color: #3a7d3a; }

/* ---- STANDALONE CHECKBOX (ZtCheckbox — list/toolbar tick, no form) ----- */
/* same custom-rendered box as the form checkbox so the two cannot drift apart visually */
.zt-checkbox { display: inline-flex; align-items: center; cursor: pointer; }
.zt-checkbox.zt-disabled { opacity: 0.5; cursor: default; }
.zt-checkbox input[type="checkbox"] {
	appearance: none;
	-webkit-appearance: none;
	width: 17px;
	height: 17px;
	border: 2px solid var(--zeal-light-grey);
	border-radius: 3px;
	background: #fff;
	cursor: pointer;
	flex: 0 0 auto;
	margin: 0;
	position: relative;
	transition: background .12s ease, border-color .12s ease;
}
.zt-checkbox.zt-disabled input[type="checkbox"] { cursor: default; }
.zt-checkbox input[type="checkbox"]:checked {
	background: var(--zeal-green);
	border-color: var(--zeal-green);
}
.zt-checkbox input[type="checkbox"]:checked::after {
	content: "";
	position: absolute;
	left: 4px;
	top: 0;
	width: 5px;
	height: 9px;
	border: solid #fff;
	border-width: 0 2px 2px 0;
	transform: rotate(45deg);
}
.zt-checkbox-label { margin-left: 8px; font-weight: 600; font-size: 0.95rem; color: var(--zeal-dark-grey); }
/* SWITCH VARIANT — the same checkbox re-dressed as a sliding pill; state and binding are identical */
.zt-checkbox.zt-switch input[type="checkbox"] {
	width: 32px;
	height: 18px;
	border: 2px solid var(--zeal-light-grey);
	border-radius: 99px;
	background: #e5e7eb;
}
.zt-checkbox.zt-switch input[type="checkbox"]::after {
	content: "";
	position: absolute;
	left: 1px;
	top: 1px;
	width: 12px;
	height: 12px;
	border: 0;
	border-radius: 50%;
	background: #fff;
	transform: none;
	box-shadow: 0 1px 2px rgba(0,0,0,.25);
	transition: left .12s ease;
}
.zt-checkbox.zt-switch input[type="checkbox"]:checked {
	background: var(--zeal-green);
	border-color: var(--zeal-green);
}
.zt-checkbox.zt-switch input[type="checkbox"]:checked::after { left: 15px; }

/* ---- VALIDATION SUMMARY ------------------------------------------------ */
.validation-errors {
	margin: 0 0 18px;
	padding: 12px 18px 12px 36px;
	list-style: disc;
	border: 1px solid #e6b9b9;
	border-left: 4px solid var(--zeal-red);
	border-radius: 4px;
	background: var(--zeal-light-red);
}
/* a clean bulleted list — kill the inherited gold bullet from site.css, use a normal disc */
.validation-errors li,
.validation-errors li.validation-message {
	display: list-item;
	list-style: disc outside;
	margin: 4px 0;
}
.validation-errors li::before { content: none !important; }
.validation-errors li::marker { color: var(--zeal-dark-red); }
.validation-errors .validation-message {
	font-size: 10pt;
	font-weight: 600;
	text-transform: none;
	color: var(--zeal-dark-red);
	line-height: 1.35;
}

/* ---- BUTTONS (ported from legacy button.scss / button-styles.scss) ------ */
.ztbtn {
	cursor: pointer;
	border: 0.5pt solid var(--zeal-grey);
	background-color: var(--zeal-grey);
	color: var(--zeal-white);
	display: inline-flex;
	align-items: center;
	justify-content: center;
	gap: 0.5em;
	font-weight: 500;
	text-align: center;
	vertical-align: middle;
	white-space: nowrap;          /* A BUTTON LABEL NEVER WRAPS — it sizes to its text */
	user-select: none;
	padding: 0.355rem 0.55rem;
	font-size: 0.85rem;
	line-height: 1.35;
	border-radius: 0.25rem;
	transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .1s ease;
}
.ztbtn:hover { filter: brightness(1.08); }
.ztbtn:active { transform: translateY(1px); outline: none; }
.ztbtn:focus { outline: none; }
.ztbtn:disabled, .ztbtn.ztbtn-disabled { background: var(--zeal-light-grey); border-color: var(--zeal-light-grey); color: var(--zeal-white); cursor: not-allowed; filter: none; }

.ztbtn-success, .ztbtn-green { border-color: var(--zeal-green); background: var(--zeal-green); }
.ztbtn-warning, .ztbtn-yellow, .ztbtn-gold { border-color: var(--zeal-gold); background: var(--zeal-gold); color: var(--zeal-dark-grey); }
.ztbtn-error, .ztbtn-red { border-color: var(--zeal-red); background: var(--zeal-red); }
.ztbtn-orange { border-color: var(--zeal-orange); background: var(--zeal-orange); }
.ztbtn-blue { border-color: var(--zeal-blue); background: var(--zeal-blue); }
.ztbtn-tiffany { border-color: var(--zeal-tiffany); background: var(--zeal-tiffany); }
.ztbtn-purple { border-color: var(--zeal-purple); background: var(--zeal-purple); }
.ztbtn-pink { border-color: var(--zeal-pink); background: var(--zeal-pink); }
.ztbtn-brown { border-color: var(--zeal-brown); background: var(--zeal-brown); }
.ztbtn-black { border-color: var(--zeal-dark-grey); background: var(--zeal-dark-grey); }
.ztbtn-silver { border-color: var(--zeal-silver); background: var(--zeal-silver); }

/* ── BARE GLYPH BUTTON (ZtIconOnlyButton) — the icon and nothing else ────────────────────────────────────────
   No box, no fill, no border. The pointer and the title tooltip come from .ztbtn and ZtButtonBase's title
   attribute, so all this has to do is strip the chrome and hand the colour to the glyph. */
.ztbtn.ztbtn-icon-only {
	padding: 0.15rem;
	border-color: transparent;
	background: transparent;
	border-radius: 0.25rem;
	font-size: 1rem;          /* the glyph is the whole control, so it runs a touch larger than a labelled button */
	line-height: 1;
}
/* HOVER IS THE ONLY CHROME IT EVER GETS — a whisper of the glyph's own colour behind it, so the target is
   findable without the button becoming a box again. Guarded: a touch screen has no hover to leave, and an
   unguarded :hover sticks to the last glyph tapped (charter rule 7a). */
@media (hover: hover) {
	.ztbtn.ztbtn-icon-only:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
}
.ztbtn.ztbtn-icon-only:disabled { opacity: 0.4; cursor: not-allowed; background: transparent; }

/* THE VERB'S COLOUR MOVES TO THE GLYPH. Same groupings as the fill classes directly above, so the two blocks
   stay readable side by side — if a colour is added there it needs a line here, and vice versa.
   ⚠️ GOLD NEEDS ITS OWN LINE for a second reason: the fill version forces dark-grey TEXT for contrast on a gold
   background, and with no background that override has to be undone or a bare Archive comes out grey. */
.ztbtn-icon-only.ztbtn-success, .ztbtn-icon-only.ztbtn-green { color: var(--zeal-green); }
.ztbtn-icon-only.ztbtn-warning, .ztbtn-icon-only.ztbtn-yellow, .ztbtn-icon-only.ztbtn-gold { color: var(--zeal-gold); }
.ztbtn-icon-only.ztbtn-error, .ztbtn-icon-only.ztbtn-red { color: var(--zeal-red); }
.ztbtn-icon-only.ztbtn-orange { color: var(--zeal-orange); }
.ztbtn-icon-only.ztbtn-blue { color: var(--zeal-blue); }
.ztbtn-icon-only.ztbtn-tiffany { color: var(--zeal-tiffany); }
.ztbtn-icon-only.ztbtn-purple { color: var(--zeal-purple); }
.ztbtn-icon-only.ztbtn-pink { color: var(--zeal-pink); }
.ztbtn-icon-only.ztbtn-brown { color: var(--zeal-brown); }
.ztbtn-icon-only.ztbtn-black { color: var(--zeal-dark-grey); }
.ztbtn-icon-only.ztbtn-silver { color: var(--zeal-silver); }
.ztbtn .zt-fa { font-size: 1.05em; }

/* ---- BUTTON SIZES (one per variant component) -------------------------- */
/* Double-class (.ztbtn.ztbtn-*) so the size survives the responsive .ztbtn shrink below. */
/* Default = the base .ztbtn above (ZtButton) */
/* Large (ZtLargeButton) */
.ztbtn.ztbtn-lg { padding: 0.8rem 1.5rem; font-size: 1.1rem; gap: 0.6em; }
.ztbtn.ztbtn-lg .zt-fa { font-size: 1.15em; }
/* Small (ZtSmallButton) — a COMPACT labeled button: icon + text, smaller font, tight horizontal padding.

   ⚠️ THE HEIGHT IS FIXED TO --zt-btn-compact-h, THE SAME VALUE ZtIconButton USES, AND THAT IS THE WHOLE POINT.
   These two are the standard pair in an action row — a help icon next to a labelled button — and they used to
   be sized two different ways: the icon button by an explicit 30px, this one by its padding (~28px). Two pixels
   out is exactly enough to look broken side by side, and it did, everywhere the pair appears (charges summary,
   the user account sections, anywhere Help sits beside an action). One shared variable means they can never
   drift again — change the pair's height in ONE place. */
.ztbtn.ztbtn-small {
	height: var(--zt-btn-compact-h);
	padding: 0 0.6rem;          /* horizontal only — the height owns the vertical rhythm now */
	font-size: 0.8rem;
	gap: 0.35em;                /* was 0.45em: the icon sat adrift from its label */
	line-height: 1.2;
}
.ztbtn.ztbtn-small .zt-fa { font-size: 1em; }
.ztbtn.ztbtn-small .ztbtn-text { white-space: nowrap; }
/* Menu tile (ZtMenuButton) — big icon, small label under.
   A 1:1 PORT of the legacy Zeal.Razor.Ui `.ztbtn-menu` (button.scss), values and all. Vince built these and
   asked for them exactly: .2/.4/.2/.45rem padding, a 1.3rem icon, and a 30px-wide 0.4rem label.

   IT SIZES TO ITS CONTENT — no fixed width/height. The previous 53x53 square was invented here, and it made
   every tile bigger and blockier than the ones it was supposed to match. The 30px label cap is what actually
   sets the width, which is why the legacy tiles are all the same size without anyone declaring one.

   ⚠️ NO text-transform. The legacy has none, and the UI charter bans faking caps in CSS — the tiles read
   "Check-Ins / Kiosk / Edit", not "CHECK-INS / KIOSK / EDIT".

   ── EVERY WAVE-EDITOR FOOTER BUTTON IS A MENU TILE. GLOBALLY. ────────────────────────────────────────────
   Vince, 2026-08-06: "when we have buttons in the footer of one, they should be menu buttons. That should be
   a global change." So the wave footer is added to THESE selectors rather than editing 24 components — a
   change made once here reaches every wave editor that exists and every one written after it, and there is
   no second copy of these values to drift from. A footer button written as a plain ZtButton/ZtSaveButton/
   ZtCancelButton renders as a tile automatically; nothing at the call site has to know.

   ICON-ONLY and SMALL-MENU footer buttons are excluded: a ZtIconButton is a fixed square by definition, and a
   ZtSmallMenuButton already asked for the smaller tile. */
.ztbtn.ztbtn-menu,
/* ⚠️ ZtSmallButton IS EXEMPT (added 2026-08-14). The tile treatment below squeezes every footer button into a
   30px ellipsised caption — right for Back / Next / Save, one short word each, and wrong the moment the LABEL is
   the information: the trial wizard's last screen rendered "Add family member" as "Add fa…". A ZtSmallButton in
   a wave footer now keeps the ordinary small icon+text shape it already has everywhere else (a modal footer
   applies no overrides at all, which is why its Close button reads correctly). Verified blast radius: the ONLY
   ZtSmallButton inside any <Footer> slot in the solution is that wizard's. */
.zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small):not(.ztbtn-small) {
	flex: 0 0 auto;            /* never shrink in a wrapping footer */
	flex-direction: column;
	justify-content: center;
	gap: 0;                    /* the label carries its own padding, as in the legacy */
	padding: 0.2rem 0.4rem 0.2rem 0.45rem;
	font-size: 0.55rem;
	font-weight: 400;          /* regular — bold blooms on dark; 400 reads cleaner small */
	line-height: 1;
	overflow: hidden;
}
.ztbtn.ztbtn-menu .zt-fa,
.zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small):not(.ztbtn-small) .zt-fa { font-size: 1.3rem; margin-top: 0.2rem; }
.ztbtn.ztbtn-menu .ztbtn-text,
.zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small):not(.ztbtn-small) .ztbtn-text {
	width: 30px;               /* THE legacy's width — this is what makes every tile match */
	text-align: center;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	font-size: 0.4rem;
	line-height: 0.25rem;
	padding-top: 0.25rem;
	padding-bottom: 0.2em;
}
/* ── THE PUBLIC-FACING OPT-OUT: ZtWaveEditor ReadableFooter="true" ───────────────────────────────────────
   Vince, 2026-08-16: "Sometimes I can't read those buttons when they're the small menu buttons. So for the
   wizards like this that are public facing to the public, let's make all of those buttons like you see in the
   last one" — the ordinary ZtSmallButton icon+text shape, which the sign-up wizard's Success step already wore.

   The tile treatment above is right INSIDE the app (Back / Next / Save are one short word and an operator uses
   them all day) and wrong in front of a stranger, where the LABEL is the information: "Add card and pay"
   ellipsised to "Add ca…" on the screen where somebody is deciding to hand over a card number.

   ⚠️ SPECIFICITY, NOT SOURCE ORDER. The tile selector carries three :not(.class) tests, each of which counts —
   it is (0,5,0), not (0,2,0). The doubled scope class here makes this (0,6,0) so it wins outright and cannot be
   broken later by someone moving a block. (The house doubled-class trick, 4th use.)

   ⚠️ THESE FOUR METRICS ARE .ztbtn-small's, RESTATED. They cannot be shared by adding this selector to that
   rule, because that rule sits ABOVE the tile rule and would lose the tie. If the compact button's size ever
   changes, change it in BOTH places — the height already comes from --zt-btn-compact-h, which is the one value
   that genuinely must never drift (it pairs with ZtIconButton). */
.zt-wave-readable.zt-wave-readable .zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small) {
	/* UNDO THE TILE — back to a normal horizontal button. */
	flex-direction: row;
	overflow: visible;
	font-weight: 500;  /* .ztbtn's own weight — the tile rule drops it to 400 */
	/* THE SMALL BUTTON'S OWN METRICS (see .ztbtn.ztbtn-small above). */
	height: var(--zt-btn-compact-h);
	padding: 0 0.6rem;
	font-size: 0.8rem;
	gap: 0.35em;
	line-height: 1.2;
}

.zt-wave-readable.zt-wave-readable .zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small) .zt-fa {
	font-size: 1em;
	margin-top: 0;
}

/* THE CAPTION IS THE POINT — no 30px cap, no ellipsis, no 0.4rem type. */
.zt-wave-readable.zt-wave-readable .zt-wave-footer .ztbtn:not(.ztbtn-icon):not(.ztbtn-menu-small) .ztbtn-text {
	width: auto;
	font-size: inherit;
	line-height: inherit;
	padding-top: 0;
	padding-bottom: 0;
	overflow: visible;
	text-overflow: clip;
}

/* Small menu tile (ZtSmallMenuButton) — nav bars / inside action cards */
.ztbtn.ztbtn-menu-small {
	flex: 0 0 auto;
	flex-direction: column;
	justify-content: center;
	gap: 0.25rem;
	width: 41px;
	height: 41px;
	min-width: 41px;
	padding: 0.15rem;
	font-size: 0.4rem;         /* small enough to fit longer labels (SCHEDULE / CHECKINS) on one line */
	font-weight: 400;
	text-transform: uppercase;
	letter-spacing: 0;
	line-height: 1.0;
	overflow: hidden;
}
.ztbtn.ztbtn-menu-small .zt-fa { font-size: 0.8rem; }
.ztbtn.ztbtn-menu-small .ztbtn-text { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Icon-only square (ZtIconButton) — the OTHER half of the compact pair; shares --zt-btn-compact-h with
   ZtSmallButton so the two always line up. */
.ztbtn.ztbtn-icon {
	flex: 0 0 auto;            /* fixed square — never shrink */
	padding: 0;
	width: var(--zt-btn-compact-h);
	height: var(--zt-btn-compact-h);
	min-width: var(--zt-btn-compact-h);
	justify-content: center;
	gap: 0;
}
.ztbtn.ztbtn-icon .zt-fa { font-size: 1rem; }
/* WITH optional text: grow to a pill (auto width + padding + gap) but KEEP the compact height so a
   labelled icon button lines up perfectly with the icon-only ones in the same row. */
.ztbtn.ztbtn-icon:has(.ztbtn-text) { width: auto; padding: 0 0.6rem; gap: 0.35em; }
.ztbtn.ztbtn-icon .ztbtn-text { font-size: 0.85rem; white-space: nowrap; }

/* ---- MODAL (native <dialog>: top-layer, native ::backdrop, ESC + focus-trap) ---------- */
dialog.zt-modal {
	padding: 0;
	border: none;
	border-radius: 0.4rem;
	width: min(620px, 92vw);
	max-width: 92vw;
	max-height: 88vh;
	overflow: hidden;
	background: var(--zeal-white);
	color: var(--zeal-dark-grey);
	box-shadow: 0 12px 38px rgba(0, 0, 0, 0.35);
}
dialog.zt-modal::backdrop {
	background: rgba(0, 0, 0, 0.5);
	animation: zt-backdrop-in 0.2s ease-out;
}
/* ⚠️ CENTRING IS STATED EXPLICITLY, NOT INHERITED FROM THE BROWSER. An open modal `<dialog>` is centred by the
   UA stylesheet (`position: fixed; inset: 0; margin: auto`) and nothing here used to restate it — which is fine
   right up until the dialog is rendered INSIDE somebody else's subtree and a stylesheet we do not own has an
   opinion about `margin`, `inset` or `position` on a block element.

   THAT IS EXACTLY WHERE THE SCHEDULE'S ⓘ POPUPS LIVE: inside `.layout-container`, the CONNECTED-SITE layout,
   alongside per-site theme blobs written by imported third-party sites. A modal that drifts off-centre on one
   page and not another is this, every time. Restating the three properties costs nothing (it is what the
   browser already does) and makes a house modal immune to the surrounding page.

   `:modal` — NOT `[open]` — deliberately: a non-modal `.show()` dialog is SUPPOSED to sit in the flow. */
dialog.zt-modal:modal { position: fixed; inset: 0; margin: auto; }
dialog.zt-modal[open] { animation: zt-modal-grow 0.22s ease-out; }
@keyframes zt-modal-grow { from { transform: scale(0.88); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes zt-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
	dialog.zt-modal[open], dialog.zt-modal::backdrop { animation: none; }
}

/* size variants (pass via Class) */
dialog.zt-modal.zt-modal-sm { width: min(380px, 92vw); }
dialog.zt-modal.zt-modal-lg { width: min(820px, 94vw); }
dialog.zt-modal.zt-modal-xl { width: min(1140px, 96vw); }
dialog.zt-modal.zt-modal-big { width: 88vw; }

.zt-modal-content { display: flex; flex-direction: column; max-height: 88vh; min-height: 0; }
/* THE HEADER BAND — halfway between the original slim bar and the 2× version (Vince, 2026-08-12 round 2). */
.zt-modal-header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.75rem;
	padding: 1rem 1.15rem;
	flex: 0 0 auto;
}
.zt-modal-title { display: flex; align-items: center; gap: 0.55rem; font-size: 1.35rem; font-weight: 700; }
.zt-modal-header-close {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	cursor: pointer;
	background: rgba(0, 0, 0, 0.18);
	color: inherit;
	border: none;
	width: 32px;
	height: 32px;
	border-radius: 50%;
	font-size: 17px;
	line-height: 1;
	flex: 0 0 auto;
}
.zt-modal-header-close:hover { background: rgba(0, 0, 0, 0.32); }
/* EVEN padding on all four sides — the bottom used to be 0.6rem against 1.15rem sides, so content (a form, its
   buttons) sat tighter to the bottom edge than to the left/right. One value = one rhythm (matches the fullscreen
   variant below, which was already uniform). */
/* BODY COPY IS GREY, NOT BLACK (Vince, 2026-08-12, nudged 15% greyer in round 2) — #6e prose, #555 bold. */
.zt-modal-body { padding: 1.15rem; overflow-y: auto; flex: 1 1 auto; min-height: 0; color: #6e6e6e; line-height: 1.55; }
.zt-modal-body b { color: #555555; }
/* kill the default first/last-child margins (e.g. a <p>) so the body's own padding is the only spacing — keeps the
   gap to the footer consistent instead of the browser's 1em <p> margin adding a random extra gap. */
.zt-modal-body > :first-child { margin-top: 0; }
.zt-modal-body > :last-child { margin-bottom: 0; }
/* THE FOOTER BAND — the wave editor's header/body/footer shape brought to modals (Vince, 2026-08-12,
   superseding the earlier no-footer-band ruling): a GREY band that frames the buttons; it renders ONLY when
   a Footer slot exists, so button-less modals stay header + body. Buttons ALWAYS left-aligned (Cancel/Save). */
.zt-modal-footer {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	justify-content: flex-start;
	gap: 0.5rem;
	padding: 0.9rem 1.15rem;
	background: #f2f2f2;
	border-top: 1px solid #e4e4e4;
	flex: 0 0 auto;
}

/* EDIT-ROLE MODAL — a compact role picker built from ZtModal + ZtDropdown; just narrow the dialog.
   (Its buttons moved into the global modal FOOTER band — the .zt-editrole-actions row is gone.) */
.zt-modal.zt-editrole-modal { width: min(420px, 92vw); }

/* 17 colour themes (header bar) — faithful to legacy modal-styles.scss */
.zt-modal-default .zt-modal-header { background: var(--zeal-modal-header); color: var(--zeal-white); }
.zt-modal-success .zt-modal-header, .zt-modal-green .zt-modal-header { background: var(--zeal-green); color: var(--zeal-white); }
.zt-modal-warning .zt-modal-header, .zt-modal-yellow .zt-modal-header, .zt-modal-gold .zt-modal-header { background: var(--zeal-gold); color: var(--zeal-dark-grey); }
.zt-modal-error .zt-modal-header, .zt-modal-red .zt-modal-header { background: var(--zeal-red); color: var(--zeal-white); }
.zt-modal-pink .zt-modal-header { background: var(--zeal-pink); color: var(--zeal-white); }
.zt-modal-orange .zt-modal-header { background: var(--zeal-orange); color: var(--zeal-white); }
.zt-modal-blue .zt-modal-header { background: var(--zeal-blue); color: var(--zeal-white); }
.zt-modal-tiffany .zt-modal-header { background: var(--zeal-tiffany); color: var(--zeal-white); }
.zt-modal-purple .zt-modal-header { background: var(--zeal-purple); color: var(--zeal-white); }
.zt-modal-brown .zt-modal-header { background: var(--zeal-brown); color: var(--zeal-white); }
.zt-modal-black .zt-modal-header { background: var(--zeal-grey); color: var(--zeal-white); }
.zt-modal-silver .zt-modal-header { background: var(--zeal-silver); color: var(--zeal-white); }
.zt-modal-bronze .zt-modal-header { background: var(--zeal-bronze); color: var(--zeal-white); }

/* ── THE TONE BANDS, ON A MODAL ────────────────────────────────────────────────────────────────────────────
   The 17 themes above are the LEGACY palette — solid, saturated bars with white text. They are not the same
   thing as the app's TONE vocabulary (.zt-happy / .zt-warn / .zt-error / .zt-neutral / .zt-plain), which is the
   pale wash + dark icon that every section card, wave editor and flush card wears. A modal is a surface like
   any other, so putting a tone class on one now gives the SAME band rather than a second, louder green
   (Vince, 2026-08-15: "the nice light green, happy background that we worked on so hard").

   ⚠️ HEAVIER THAN .zt-modal-default (3 classes vs 2) ON PURPOSE — source order alone would NOT win, so a toned
   modal that also carries a legacy ModalStyle would otherwise flip back to the solid bar. Use ONE or the other.
   The colours come from the shared --zt-*-bg/-bd tokens, so a modal can never drift from the cards. */
.zt-modal.zt-happy   .zt-modal-header { background: var(--zt-happy-bg);   border-bottom: 1px solid var(--zt-happy-bd); }
.zt-modal.zt-warn    .zt-modal-header { background: var(--zt-warn-bg);    border-bottom: 1px solid var(--zt-warn-bd); }
.zt-modal.zt-error   .zt-modal-header { background: var(--zt-error-bg);   border-bottom: 1px solid var(--zt-error-bd); }
.zt-modal.zt-neutral .zt-modal-header { background: var(--zt-neutral-bg); border-bottom: 1px solid var(--zt-neutral-bd); }
/* THE TITLE READS DARK ON THE WASH — the band is meant to be barely noticed; the words carry normally. */
.zt-modal.zt-happy .zt-modal-header,
.zt-modal.zt-warn .zt-modal-header,
.zt-modal.zt-error .zt-modal-header,
.zt-modal.zt-neutral .zt-modal-header { color: var(--zeal-dark-grey); }
/* …AND THE ICON CARRIES THE STRONG COLOUR, exactly as it does on a card title (see THE TITLE ICON block). */
.zt-modal.zt-happy   .zt-modal-title .zt-fa { color: var(--zeal-dark-green); }
.zt-modal.zt-warn    .zt-modal-title .zt-fa { color: var(--zeal-dark-gold); }
.zt-modal.zt-error   .zt-modal-title .zt-fa { color: var(--zeal-dark-red); }
/* The close disc is tuned for a WHITE-on-dark bar; on a pale band that same 18% black reads as a bruise. */
.zt-modal.zt-happy .zt-modal-header-close,
.zt-modal.zt-warn .zt-modal-header-close,
.zt-modal.zt-error .zt-modal-header-close,
.zt-modal.zt-neutral .zt-modal-header-close { background: rgba(0, 0, 0, 0.07); }
.zt-modal.zt-happy .zt-modal-header-close:hover,
.zt-modal.zt-warn .zt-modal-header-close:hover,
.zt-modal.zt-error .zt-modal-header-close:hover,
.zt-modal.zt-neutral .zt-modal-header-close:hover { background: rgba(0, 0, 0, 0.15); }

/* FULL-SCREEN editor variant — a ZtModal (Class="zt-modal-fullscreen") that covers the whole viewport, for
   mobile-first editors (photo crop, etc.). Header (icon+title+close) and footer stay; the body fills + scrolls.
   Footer buttons are LEFT-aligned here (Cancel grey, Save green) per the Zeal modal standard. */
dialog.zt-modal.zt-modal-fullscreen {
	width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0;
}
.zt-modal-fullscreen .zt-modal-content { height: 100dvh; max-height: 100dvh; }
.zt-modal-fullscreen .zt-modal-footer { justify-content: flex-start; }
/* The photo editor fills the sheet: the crop frame grows to the available height (keeping its aspect via the
   inline aspect-ratio), centered, with even margins; the one button row is left-aligned (Cancel far left). */
.zt-modal-fullscreen .zt-modal-body { padding: 1.25rem; }
/* Round avatar (1:1): fill the available HEIGHT (a CONCRETE dim so it can't collapse); width follows the
   inline aspect-ratio. Wide cover: fill the WIDTH; height follows the aspect. */
.zt-modal-fullscreen .zt-fileinput-frame.zt-fileinput-round {
	width: auto !important;
	height: min(58vh, 86vw);
	max-width: 100%;
	margin-inline: auto;
}
.zt-modal-fullscreen .zt-fileinput-frame:not(.zt-fileinput-round) {
	width: 100% !important;
	height: auto;
}
.zt-modal-fullscreen .zt-fileinput-actions { justify-content: flex-start; }

/* ---- TOAST (our code: pure Blazor + CSS, no JS; CSS countdown drives auto-dismiss) ---------- */
.zt-toast-container {
	--zt-toast-gap: 1rem;          /* ONE consistent inset on every edge — change it here, applies everywhere */
	position: fixed;
	z-index: 999999;
	display: flex;
	flex-direction: column;
	gap: 0.5rem;
	/* DEFINITE width (not just max-width): the container is fixed-positioned with only `right`/`left` set, so a bare
	   max-width leaves it SHRINK-TO-FIT — and the toast inside is `width: 100%`, a percentage resolving against a
	   parent whose width is derived from that same child. That cycle made toasts size to their text (a short message
	   rendered a half-width "baby" toast). A definite width gives the percentage something real to resolve against,
	   so every toast is the SAME size everywhere. Still never wider than the viewport minus the gaps + notch insets. */
	width: min(30rem, calc(100vw - 2 * var(--zt-toast-gap) - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
	max-width: min(30rem, calc(100vw - 2 * var(--zt-toast-gap) - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)));
	pointer-events: none;          /* let clicks through the gaps; toasts re-enable below */
}
/* POSITIONS — every edge uses the SAME --zt-toast-gap, PLUS the device safe-area inset (env(safe-area-inset-*) is
   0 where there's no notch/home indicator, so this is a no-op on desktop and correct on iOS/Android). The BOTTOM
   positions additionally clear the fixed nav bar via its LIVE height --zt-navbar-h (published by
   ztNav.publishNavHeight, and re-measured when the bar wraps to two rows) — so toasts always sit just above the
   nav, one row or two, on every screen size. 64px fallback ≈ one nav row, until JS publishes the exact height. */
.zt-position-topright    { top: calc(env(safe-area-inset-top, 0px) + var(--zt-toast-gap)); right: calc(env(safe-area-inset-right, 0px) + var(--zt-toast-gap)); align-items: flex-end; }
.zt-position-topleft     { top: calc(env(safe-area-inset-top, 0px) + var(--zt-toast-gap)); left: calc(env(safe-area-inset-left, 0px) + var(--zt-toast-gap)); align-items: flex-start; }
.zt-position-topcenter   { top: calc(env(safe-area-inset-top, 0px) + var(--zt-toast-gap)); left: 50%; transform: translateX(-50%); align-items: center; }
.zt-position-bottomright  { bottom: calc(var(--zt-navbar-h, 64px) + env(safe-area-inset-bottom, 0px) + var(--zt-toast-gap)); right: calc(env(safe-area-inset-right, 0px) + var(--zt-toast-gap)); align-items: flex-end; flex-direction: column-reverse; }
.zt-position-bottomleft   { bottom: calc(var(--zt-navbar-h, 64px) + env(safe-area-inset-bottom, 0px) + var(--zt-toast-gap)); left: calc(env(safe-area-inset-left, 0px) + var(--zt-toast-gap)); align-items: flex-start; flex-direction: column-reverse; }
.zt-position-bottomcenter { bottom: calc(var(--zt-navbar-h, 64px) + env(safe-area-inset-bottom, 0px) + var(--zt-toast-gap)); left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }

/* WHILE A WAVE EDITOR IS OPEN — the nav is HIDDEN, so the bottom positions must not reserve its height (that gap left
   the toast floating in mid-air). Position and SIZE are otherwise UNCHANGED: a toast looks and sits exactly the same
   over a wave editor as it does anywhere else in the app. */
body.zt-wave-open .zt-position-bottomright,
body.zt-wave-open .zt-position-bottomleft,
body.zt-wave-open .zt-position-bottomcenter {
	bottom: calc(env(safe-area-inset-bottom, 0px) + var(--zt-toast-gap));
}

.zt-toast {
	position: relative;
	display: flex;
	flex-direction: row;
	align-items: center;   /* leading icon centered vertically against the whole toast */
	width: 100%;
	padding: 0.85rem 1rem;
	border-radius: 0.35rem;
	color: var(--zeal-white);
	box-shadow: 0 6px 16px rgba(0, 0, 0, 0.28);
	overflow: hidden;
	pointer-events: auto;
	animation: zt-toast-in 0.25s ease-out;
}
@keyframes zt-toast-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .zt-toast { animation: none; } }

.zt-toast-icon { flex: 0 0 auto; display: flex; align-items: center; font-size: 1.9rem; padding-right: 0.85rem; }
.zt-toast-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.zt-toast-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.5rem; }
.zt-toast-heading { margin: 0; font-weight: 700; text-transform: uppercase; font-size: 1.05rem; line-height: 1.3; }
.zt-toast-message { margin: 0.1rem 0 0; font-size: 0.95rem; line-height: 1.35; word-break: break-word; }
.zt-toast-close {
	flex: 0 0 auto;
	margin-left: auto;
	background: transparent;
	border: 0;
	color: inherit;
	cursor: pointer;
	font-size: 1rem;
	line-height: 1;
	opacity: 0.85;
}
.zt-toast-close:hover { opacity: 1; }

/* countdown progress bar — visual only; the container's timer does the actual dismiss (same duration) */
.zt-toast-progressbar { position: absolute; left: 0; right: 0; bottom: 0; height: 5px; overflow: hidden; }
.zt-toast-progressbar > span { display: block; height: 100%; width: 100%; background: rgba(255, 255, 255, 0.6); animation: zt-toast-countdown var(--zt-toast-duration, 5s) linear forwards; }
@keyframes zt-toast-countdown { from { width: 100%; } to { width: 0%; } }

/* compact ("baby") — inline icon + message + close, no title/progress */
.zt-toast-compact { align-items: center; padding: 0.5rem 0.85rem; }
.zt-toast-compact .zt-toast-icon { font-size: 1.15rem; padding-right: 0.5rem; }
.zt-toast-compact .zt-toast-body { flex-direction: row; align-items: center; }
.zt-toast-compact .zt-toast-header { display: contents; }
/* message fills the row (order keeps it before the close) so the X is pushed to the right edge,
   consistent with the full toasts */
.zt-toast-compact .zt-toast-message { font-size: 0.88rem; flex: 1 1 auto; order: 1; }
.zt-toast-compact .zt-toast-close { order: 2; margin-left: 0.75rem; }

/* 19 colour themes (white text except the light ones) */
.zt-toast-default { background: var(--zeal-dark-grey); }
.zt-toast-info, .zt-toast-blue { background: var(--zeal-blue); }
.zt-toast-success, .zt-toast-green { background: var(--zeal-green); }
.zt-toast-warning, .zt-toast-yellow, .zt-toast-gold { background: var(--zeal-gold); color: var(--zeal-dark-grey); }
.zt-toast-error, .zt-toast-red { background: var(--zeal-red); }
.zt-toast-pink { background: var(--zeal-pink); }
.zt-toast-orange { background: var(--zeal-orange); }
.zt-toast-tiffany { background: var(--zeal-tiffany); }
.zt-toast-purple { background: var(--zeal-purple); }
.zt-toast-brown { background: var(--zeal-brown); }
.zt-toast-black { background: var(--zeal-grey); }
.zt-toast-silver { background: var(--zeal-silver); }
.zt-toast-bronze { background: var(--zeal-bronze); }
.zt-toast-warning .zt-toast-progressbar > span, .zt-toast-yellow .zt-toast-progressbar > span, .zt-toast-gold .zt-toast-progressbar > span { background: rgba(0, 0, 0, 0.28); }

/* ====================================================================== */
/* ZtAlert — inline banner (the in-page sibling of the toast).            */
/* Soft tint + accent bar by default; .zt-alert-solid fills it. One colour */
/* var per severity drives the whole look via color-mix.                   */
/* ====================================================================== */
.zt-alert {
	--zt-alert-color: var(--zeal-blue);
	display: flex; align-items: flex-start; gap: 12px;
	margin-bottom: 0.75em;                 /* SAME VERTICAL RHYTHM AS .zt-input-container */
	padding: 12px 14px;
	border-radius: var(--zeal-radius, 5px);
	border: 1px solid color-mix(in srgb, var(--zt-alert-color) 28%, transparent);
	border-left: 4px solid var(--zt-alert-color);
	background: color-mix(in srgb, var(--zt-alert-color) 9%, #fff);
	color: #383838;
	font-family: var(--zt-font);
	font-size: 0.92rem; line-height: 1.45;
	animation: zt-alert-in 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes zt-alert-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }

/* ⭐ INFO WEARS ZEALLAB'S "RUNNING" CARD, EXACTLY (Vince, 2026-08-23: "that's the colour of the background,
   the colour of the font, the type of font, everything that I would like in the info alert… can we make a
   global change"). Lifted verbatim from Zeal.Deploy/wwwroot/app.css .run-status.running.

   ⚠️ WHY IT LOOKED PURPLE, since the shape was already right: the base rule tints the fill with
   color-mix(--zt-alert-color 9%, #fff), and --zeal-blue is #2563eb — a violet-leaning blue. Nine percent
   of it over white lands on lavender. The ACCENT was never the problem (Vince: "the accent colours there
   are great"), so it keeps --zeal-blue; only the fill and the hairline border are pinned to the literal
   values, which is the one case a literal beats a token — we are matching another product's surface. */
.zt-alert-info {
	--zt-alert-color: var(--zeal-blue);
	background: #f5f8ff;
	border-color: #e2e4ea;
	border-left-color: var(--zeal-blue);
}
.zt-alert-success { --zt-alert-color: var(--zeal-green); }
.zt-alert-warning { --zt-alert-color: var(--zeal-gold); }
.zt-alert-error   { --zt-alert-color: var(--zeal-red); }
.zt-alert-tiffany { --zt-alert-color: var(--zeal-tiffany); }
.zt-alert-gold    { --zt-alert-color: var(--zeal-gold); }
.zt-alert-neutral { --zt-alert-color: var(--zeal-medium-grey); }

.zt-alert-icon { flex: 0 0 auto; color: var(--zt-alert-color); font-size: 1.1rem; line-height: 1.45; }
.zt-alert-body { flex: 1 1 auto; min-width: 0; }
.zt-alert-title { font-weight: 700; margin-bottom: 2px; color: color-mix(in srgb, var(--zt-alert-color) 62%, #1c1c1c); }
.zt-alert-message { color: #3a3a3a; }
.zt-alert-message > :last-child { margin-bottom: 0; }       /* TRIM TRAILING <p> MARGIN ON RICH BODIES */
.zt-alert-message a { color: var(--zt-alert-color); font-weight: 600; }
.zt-alert-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
.zt-alert-close {
	flex: 0 0 auto; padding: 0; margin: -2px -4px 0 0; border: none; background: none;
	color: #9aa0a3; cursor: pointer; font-size: 1rem; line-height: 1; transition: color 0.15s ease;
}
.zt-alert-close:hover { color: var(--zt-alert-color); }

/* ---- SOLID (filled) variant ---- */
.zt-alert-solid { background: var(--zt-alert-color); border-color: var(--zt-alert-color); color: #fff; }
.zt-alert-solid .zt-alert-icon,
.zt-alert-solid .zt-alert-title,
.zt-alert-solid .zt-alert-message { color: #fff; }
.zt-alert-solid .zt-alert-message a { color: #fff; text-decoration: underline; }
.zt-alert-solid .zt-alert-close { color: rgba(255, 255, 255, 0.75); }
.zt-alert-solid .zt-alert-close:hover { color: #fff; }
/* GOLD/WARNING ARE TOO LIGHT FOR WHITE TEXT WHEN FILLED — USE DARK INK. */
.zt-alert-solid.zt-alert-warning, .zt-alert-solid.zt-alert-gold { color: var(--zeal-dark-grey); }
.zt-alert-solid.zt-alert-warning .zt-alert-icon, .zt-alert-solid.zt-alert-gold .zt-alert-icon,
.zt-alert-solid.zt-alert-warning .zt-alert-title, .zt-alert-solid.zt-alert-gold .zt-alert-title,
.zt-alert-solid.zt-alert-warning .zt-alert-message, .zt-alert-solid.zt-alert-gold .zt-alert-message { color: var(--zeal-dark-grey); }
.zt-alert-solid.zt-alert-warning .zt-alert-close, .zt-alert-solid.zt-alert-gold .zt-alert-close { color: rgba(0, 0, 0, 0.55); }

@media (prefers-reduced-motion: reduce) { .zt-alert { animation: none; } }

/* ====================================================================== */
/* ZtRichTextEditor — command bar + contenteditable surface + modals       */
/* ====================================================================== */
/* The editor is wider than a normal field, so let it fill the container. */
.zt-rte-root .zt-input-container { max-width: 100%; }
/* the big left field-icon doesn't suit a full-width editor — hide it like we do for textareas */
.zt-rte-root .zt-input-field-icon { display: none; }
.zt-rte { display: flex; flex-direction: column; border: 1px solid var(--zeal-silver, #c8ccce); border-radius: 6px; background: #fff; }
.zt-rte-surface, .zt-rte-code { border-radius: 0 0 6px 6px; }

/* ---- COMMAND BAR — a distinct toolbar with real-looking button tiles ---- */
.zt-rte-bar {
	display: flex;
	flex-direction: column;
	gap: 5px;
	padding: 6px 7px;
	background: linear-gradient(#fbfcfc, #eef1f3);
	border-bottom: 1px solid var(--zeal-silver, #c8ccce);
	border-radius: 6px 6px 0 0;
}
.zt-rte-bar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
/* THE CLOSE ✕ RIDES THE RIGHT EDGE of the toolbar's first row. margin-left:auto rather than a float or an
   absolute position, so it stays inside the row's flex flow and wraps with it on a narrow toolbar instead of
   sitting on top of the formatting buttons. */
.zt-rte-bar-close { margin-left: auto; display: inline-flex; align-items: center; }
.zt-rte-bar-row2 { padding-top: 5px; border-top: 1px solid rgba(0, 0, 0, 0.06); }
.zt-rte-btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 29px;
	border: 1px solid #cdd3d6;
	border-radius: 5px;
	background: #fff;
	color: #3a4145;
	font-size: 0.88rem;
	cursor: pointer;
	box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
	transition: background .1s, border-color .1s, color .1s, box-shadow .1s;
}
.zt-rte-btn:hover { background: #eafaf9; border-color: var(--zeal-tiffany, #08bab5); color: var(--zeal-tiffany, #08bab5); }
.zt-rte-btn:active { box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15); background: #def5f4; }
.zt-rte-btn.active { background: var(--zeal-tiffany, #08bab5); border-color: var(--zeal-tiffany, #08bab5); color: #fff; box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2); }
.zt-rte-sep { width: 1px; align-self: stretch; margin: 2px 3px; background: #cdd3d6; }

/* ---- FONT / SIZE DROPDOWNS — obvious button + caret + menu ---- */
.zt-rte-dd { position: relative; }
.zt-rte-dd-btn {
	display: inline-flex;
	align-items: center;
	gap: 6px;
	height: 29px;
	padding: 0 7px;
	border: 1px solid #cdd3d6;
	border-radius: 5px;
	background: #fff;
	color: #3a4145;
	font-size: 0.78rem;
	font-weight: 600;
	cursor: pointer;
	box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
	font-family: var(--zt-font, system-ui, sans-serif);
}
.zt-rte-dd-btn:hover { background: #eafaf9; border-color: var(--zeal-tiffany, #08bab5); color: var(--zeal-tiffany, #08bab5); }
.zt-rte-dd-label { text-align: left; }
.zt-rte-dd-caret { font-size: 0.7rem; opacity: 0.7; transition: transform .12s; }
.zt-rte-dd.open .zt-rte-dd-caret { transform: rotate(180deg); }
.zt-rte-dd.open .zt-rte-dd-btn { border-color: var(--zeal-tiffany, #08bab5); color: var(--zeal-tiffany, #08bab5); }
.zt-rte-dd-menu {
	position: absolute;
	top: calc(100% + 4px);
	left: 0;
	z-index: 60;
	min-width: 150px;
	max-height: 260px;
	overflow-y: auto;
	background: #fff;
	border: 1px solid #cdd3d6;
	border-radius: 6px;
	box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
	padding: 4px;
}
.zt-rte-dd-menu-sm { min-width: 84px; }
.zt-rte-dd-item {
	display: block;
	width: 100%;
	text-align: left;
	padding: 7px 10px;
	border: none;
	border-radius: 4px;
	background: transparent;
	color: var(--zeal-dark-grey);
	font-size: 0.85rem;
	cursor: pointer;
	white-space: nowrap;
}
.zt-rte-dd-item:hover { background: #eafaf9; color: var(--zeal-tiffany, #08bab5); }
.zt-rte-dd-backdrop { position: fixed; inset: 0; z-index: 55; background: transparent; }
.zt-rte-dd-font .zt-rte-dd-label { min-width: 92px; }
.zt-rte-dd-iconbtn { padding: 0 6px; gap: 3px; }
.zt-rte-dd-iconbtn .zt-fa { font-size: 0.9rem; }
/* split list button — main applies the default list, caret opens the style menu */
.zt-rte-split { position: relative; display: inline-flex; }
.zt-rte-split .zt-rte-split-main { border-radius: 5px 0 0 5px; border-right-width: 0; }
.zt-rte-split .zt-rte-split-caret { width: 18px; min-width: 18px; padding: 0; border-radius: 0 5px 5px 0; }
.zt-rte-split .zt-rte-split-caret .zt-rte-dd-caret { font-size: 0.62rem; opacity: 0.7; transition: transform .12s; }
.zt-rte-split.open .zt-rte-split-caret { background: var(--zeal-tiffany, #08bab5); border-color: var(--zeal-tiffany, #08bab5); color: #fff; }
.zt-rte-split.open .zt-rte-split-main { border-color: var(--zeal-tiffany, #08bab5); }
.zt-rte-split.open .zt-rte-dd-caret { transform: rotate(180deg); opacity: 1; }
/* bullet library — a Word-style grid of bullet glyphs */
.zt-rte-bullets { display: grid; grid-template-columns: repeat(5, 34px); gap: 4px; }
.zt-rte-bullet { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; border: 1px solid #e3e6e7; border-radius: 5px; background: #fff; cursor: pointer; }
.zt-rte-bullet:hover { border-color: var(--zeal-tiffany, #08bab5); background: #eafaf9; }
.zt-rte-bullet-mark { font-size: 1rem; line-height: 1; color: var(--zeal-dark-grey); }
/* ordered-list "Numbering Library" — built-ins + @counter-style paren/bracket variants */
@counter-style zt-decimal-paren { system: extends decimal; suffix: ") "; }
@counter-style zt-decimal-bracket { system: extends decimal; prefix: "("; suffix: ") "; }
@counter-style zt-lower-alpha-paren { system: extends lower-alpha; suffix: ") "; }
@counter-style zt-upper-alpha-paren { system: extends upper-alpha; suffix: ") "; }
.zt-rte-numbers { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; min-width: 240px; }
.zt-rte-number { display: flex; align-items: center; justify-content: center; height: 34px; border: 1px solid #e3e6e7; border-radius: 5px; background: #fff; cursor: pointer; font-size: 0.8rem; font-weight: 600; color: #444; padding: 0 4px; }
.zt-rte-number:hover { border-color: var(--zeal-tiffany, #08bab5); background: #eafaf9; color: var(--zeal-tiffany, #08bab5); }
/* font-colour + highlight pickers — icon + current-colour swatch + caret */
.zt-rte-colorbtn { padding: 0 6px; gap: 3px; }
.zt-rte-colorbtn .zt-fa { font-size: 0.85rem; }
.zt-rte-swatch { display: inline-block; width: 14px; height: 14px; border: 1px solid #b9c0c3; border-radius: 3px; background: #444; flex: 0 0 auto; }
.zt-rte-colormenu { display: grid; grid-template-columns: repeat(6, 26px); gap: 4px; }
.zt-rte-color { width: 26px; height: 26px; border: 1px solid #d4d9db; border-radius: 4px; cursor: pointer; padding: 0; }
.zt-rte-color:hover { outline: 2px solid var(--zeal-tiffany, #08bab5); outline-offset: 1px; }
.zt-rte-color-none { background: #fff !important; position: relative; overflow: hidden; }
.zt-rte-color-none::after { content: ""; position: absolute; inset: 0; background: linear-gradient(to top right, transparent 46%, #e74c3c 46%, #e74c3c 54%, transparent 54%); }
/* link modal — left-aligned footer buttons */
.zt-rte-modal .zt-modal-footer { justify-content: flex-start; }

/* ---- RTE MEDIA WAVE — the staged Insert Picture / Video flow ---- */
/* THE HIDDEN InputFile — a HOUSE ZtButton opens it via ztRte.pickFile; display:none still .click()s fine. */
.zt-rte-fileinput-hidden { display: none; }
.zt-rte-pickrow { display: flex; margin-top: 0.35rem; }
/* HAPPY HEADER, GREY FOOTER (Vince, 2026-08-12) — overrides the wave tone rule (footer normally matches). */
.zt-rte-media-wave .zt-wave-footer { background: #fafafa; border-top-color: #ececec; }
/* THE LIBRARY GRID — tappable thumbnail tiles (auto-fill keeps it phone-friendly). */
.zt-rte-library {
	display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px;
	margin-top: 0.5rem;
}
.zt-rte-library-tile {
	display: flex; flex-direction: column; padding: 0; border: 1px solid #e2e2e2;
	border-radius: 8px; overflow: hidden; background: #ffffff; cursor: pointer;
	transition: box-shadow 0.12s ease, border-color 0.12s ease;
}
.zt-rte-library-tile:hover { border-color: var(--zeal-tiffany, #08bab5); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); }
.zt-rte-library-tile img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
.zt-rte-library-caption {
	padding: 4px 6px; font-size: 8pt; color: #555555; text-align: left;
	white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* file picker — a clean Zeal button instead of the native Win3.1 control */
.zt-rte-filepicker { display: inline-flex; cursor: pointer; position: relative; }
.zt-rte-filepicker-btn {
	display: inline-flex; align-items: center; gap: 8px;
	padding: 9px 16px; border-radius: 6px;
	background: var(--zeal-tiffany, #08bab5); color: #fff; font-weight: 600; font-size: 0.9rem;
	border: 1px solid var(--zeal-tiffany, #08bab5);
}
.zt-rte-filepicker:hover .zt-rte-filepicker-btn { filter: brightness(1.08); }
.zt-rte-fileinput { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }

.zt-rte-surface {
	padding: 12px 14px;
	font-family: var(--zt-font, system-ui, sans-serif);
	font-size: 1rem;
	line-height: 1.5;
	color: #222;
	outline: none;
	overflow-y: auto;
	max-height: 60vh;
}
/* ═══ CONTEXT: EMAIL (SurfaceClass="zt-rte-email") ══════════════════════════════════════════════════════
   MIRROR THE ACTUAL EMAIL RENDER EXACTLY. Every branded email — the welcome and trial-class letters via
   ConnectedAccountHeader, comms via BulkLayout — wraps the body in:

       <style>p { line-height: 1.2; margin-top: 0; margin-bottom: 0; }</style>
       <body style="font-family: sans-serif; color: #333333;">

   So the paragraph reset (NO vertical margin, 1.2 line-height) is what makes the SENT email tight. Reproduce
   it here or the editor shows a typography the recipient will never see — which is exactly what made the
   ported letter look wrong next to the legacy (Vince, 2026-08-05). Ported verbatim from the legacy
   zt-richtext.css. */
.zt-rte-surface.zt-rte-email {
	font-family: sans-serif;
	color: #333333;
	line-height: 1.2;
}
.zt-rte-surface.zt-rte-email p {
	line-height: 1.2;
	margin: 0;      /* EMAIL RESET: p HAS NO VERTICAL MARGIN — MATCHES ConnectedAccountHeader/BulkLayout */
}

/* ═══ SURFACE SCOPE WRAPPER (SurfaceScopeClass) ═══════════════════════════════════════════════════════
   Default = display:contents, a pure layout no-op — every existing editor renders EXACTLY as before.
   With a scope class (the section editor passes "layout-container") the wrapper becomes the destination's
   real CSS scope: the surface (SurfaceClass="zt-page-section") is then a genuine site section card styled
   by the site's own stylesheet chain — zt-ui's canvas base + connected-site-default (+ a theme blob later).
   True WYSIWYG: the editor shows what Save will publish (Vince, 2026-08-12). */
.zt-rte-scope { display: contents; }
.zt-rte-scope.zt-rte-collapsed { display: none; }
/* ⚠️ AND AGAIN, WITH THE SCOPE CLASS, BECAUSE THE RULE BELOW OUTRANKS THE ONE ABOVE.
   .zt-rte-scope.layout-container sets display:block at EQUAL specificity and LATER in the file, so
   on the connected-site section editor — the one place that passes SurfaceScopeClass="layout-container"
   — the collapsed surface stayed visible and the code/CSS pane rendered ON TOP OF the live surface.
   That is why "code view is not working" was true HERE and nowhere else (Vince, 2026-08-18). */
.zt-rte-scope.layout-container.zt-rte-collapsed { display: none; }
.zt-rte-scope.layout-container {
	/* THE WRAPPER TAKES OVER THE SURFACE'S SCROLL ROLE; the site sheet paints the grey gutter (#777). */
	display: block; overflow-y: auto; max-height: 60vh;
	padding: 14px 14px 20px;                    /* beats the sheet's page-level padding-bottom (nav clearance) */
	border-radius: 0 0 6px 6px;
}
/* THE SURFACE IS THE SECTION CARD — let the site rules own its look: the RTE's own font/colour lose to
   inheritance from .layout-container, and the card chrome comes from .layout-container .zt-page-section. */
.zt-rte-scope.layout-container .zt-rte-surface {
	font-family: inherit; font-size: inherit; line-height: inherit; color: inherit;
	max-height: none; overflow-y: visible;
}
/* FULLSCREEN: the scoped wrapper is the flex child now — it stretches; the surface grows inside it. */
.zt-rte-fs .zt-rte-scope.layout-container { flex: 1 1 auto; min-height: 0; max-height: none; }

.zt-rte-surface:focus { box-shadow: inset 0 0 0 2px rgba(8, 186, 181, 0.25); }
.zt-rte-surface:empty::before { content: attr(data-placeholder); color: #9aa0a3; }
.zt-rte-surface img, .zt-rte-surface video { max-width: 100%; height: auto; border-radius: 4px; }
.zt-rte-surface a { color: var(--zeal-tiffany, #08bab5); text-decoration: underline; }
.zt-rte-surface ul { list-style: disc; margin: 0.5em 0; padding: 0 0 0 1.7em; text-align: inherit; }
.zt-rte-surface ol { list-style: decimal; margin: 0.5em 0; padding: 0 0 0 1.7em; text-align: inherit; }
.zt-rte-surface li { position: static; font-size: inherit; line-height: 1.5; margin: 0.2em 0; padding-left: 0.2em; }
.zt-rte-surface li::marker { color: #555; font-size: inherit; }
.zt-rte-surface li::before, .zt-rte-surface li::after { content: none !important; }
.zt-rte-surface p { margin: 0 0 0.6em; }
.zt-rte-collapsed { display: none; }
.zt-rte-code {
	width: 100%;
	min-height: 220px;
	max-height: 60vh;
	border: none;
	outline: none;
	resize: vertical;
	padding: 12px 14px;
	font-family: "Courier New", monospace;
	font-size: 0.82rem;
	line-height: 1.5;
	color: #2b3a42;
	background: #fbfcfc;
}

/* ═══ .zt-imported-host — THE CANVAS FOR AN IMPORTED SITE, DELIBERATELY ALMOST EMPTY ═══
   An imported site renders here INSTEAD OF .layout-container, which is our house canvas and would paint
   our typography, link colours and section card into somebody else's site. ⚠️ DO NOT ADD TYPOGRAPHY,
   COLOUR, LINK, CARD OR SPACING RULES HERE — the only thing this may carry is clearance for OUR fixed
   chrome, which sits over their document. Anything else belongs in the site's own imported stylesheet. */
.zt-imported-host { box-sizing: border-box; width: 100%; background: transparent; }

/* CLEARANCE = EXACTLY WHAT THE FIXED CHROME COVERS, TO THE PIXEL. These paddings paint as the site's own
   body colour, so every pixel past the chrome is a visible strip of nothing — Vince, 2026-08-19, off the
   first Wentzville render: "there's a definite gap at the head and the foot… it doesn't look weird when
   we scroll all the way up or all the way down." The bar is var(--zt-navbar-h); the builder toolbar is
   its own 60px on top of that in the editor. No side gutter: an imported site is full-bleed by design,
   and 20px of ours would inset every full-width band they built. (The top-docked variants live with the
   other nav-position rules further down — same arithmetic, other edge.) */
.zt-site-editor .zt-imported-host { min-height: 100dvh; padding: 0 0 calc(var(--zt-navbar-h, 60px) + 60px); }

/* THE FLOATING SECTION-EDIT RAIL (editor only) — a SIBLING overlay of the imported document, never inside
   it (their bare-element CSS would restyle our button). The host anchors the coordinate space; the rail
   spans it but swallows no clicks — only the buttons themselves are interactive. Each button is parked at
   its section's top-right by ztImportedEdit.sync(). Above the imported content, below the wave editors. */
.zt-imported-edit-host { position: relative; }
.zt-imported-edit-rail { position: absolute; inset: 0; pointer-events: none; z-index: 800; }
.zt-imported-edit-fab {
	/* LEFT EDGE (Vince, 2026-08-19) — the right edge is where THEIR content puts its own calls to action,
	   and where our fullscreen toggle already lives. */
	position: absolute; left: 14px; top: 0;
	pointer-events: auto;
	/* LEGIBLE ON ANY SITE — a soft house-dark puck under the icon button, so the pencil reads on light
	   and dark bands alike without borrowing anything from THEIR palette. */
	background: rgba(43, 43, 43, 0.78);
	border-radius: 10px;
	box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
	transition: opacity 0.15s ease;
	opacity: 0.72;
}
.zt-imported-edit-fab:hover { opacity: 1; }
.zt-site-preview .zt-imported-host { padding-bottom: var(--zt-navbar-h, 60px); }
.zt-imported-host.zt-tenant-public { padding-bottom: var(--zt-navbar-h, 60px); }

/* CSS PANE — the scope note above the stylesheet textarea.  ⚠️ THIS PANE IS SITE-SCOPED while the
   rich text and code panes either side of it are section-scoped; the note is the only thing telling
   the user that, so it is part of the control, not decoration. */
.zt-rte-css-note {
	display: flex;
	align-items: center;
	gap: 8px;
	padding: 8px 14px;
	font-size: 0.78rem;
	line-height: 1.4;
	color: #2b3a42;
	background: #eef4f7;
	border-bottom: 1px solid #d7e3e9;
}

/* full-screen edit mode — consistent ~5% dark frame (#444) on all sides; surface fills the area */
/* ═══ FULL SCREEN ═══════════════════════════════════════════════════════════════════════════════════════
   TWO MECHANISMS, ONE LAYOUT.

   .zt-rte-fs        — added whenever the editor is full screen, EITHER way. It owns the LAYOUT (the flex-fill
                       chain), so native and fallback are spaced identically and there is one place to tune.
   :fullscreen       — the NATIVE path. Renders in the browser's TOP LAYER, so no ancestor can clip it.
   .zt-rte-fullscreen — the FALLBACK for iOS Safari and anything that refuses the request (iOS supports
                       requestFullscreen on <video> ONLY). position:fixed is clippable by any ancestor with
                       transform/filter/contain/will-change, so ztRte neutralises those ancestors while it is
                       up and restores them on exit. Both paths therefore fill the screen.

   ⚠️ NO MAGIC HEIGHTS. This used to say height: calc(90vh - 134px) — a guess at the chrome's height that was
   wrong, which is why the editor stopped short and left a slab of dead grey below it (Vince, 2026-08-05).
   The chain is a flex column and the surface takes flex:1; it fills whatever is actually left. */
.zt-rte-root:fullscreen,
.zt-rte-root:-webkit-full-screen,
.zt-rte-fullscreen {
	background: #444444;
	padding: var(--zt-rte-fs-pad, 1rem);
	overflow: hidden;
	box-sizing: border-box;
}

.zt-rte-root:fullscreen,
.zt-rte-root:-webkit-full-screen { width: 100vw; height: 100vh; margin: 0; }

/* z 9400: OVER the platform navbar (9000) so nothing pokes through, UNDER the wave editors (9500) so the
   media wave still opens on top — the native top-layer path is retired (it hid the media wave entirely).
   DOUBLED SELECTOR (0,2,0) — the image-tools block's `.zt-rte-root { position:relative }` sits LATER in this
   file and silently beat position:fixed at (0,1,0), un-fullscreening the editor (Vince, 2026-08-13). */
.zt-rte-root.zt-rte-fullscreen { position: fixed; inset: 0; z-index: 9400; }

/* SAME TRAP, THIRD CUSTOMER (after the site editor and the wave editors): ANY transform/animation state on
   the page-transition wrapper makes it the containing block for fixed descendants, so "fullscreen" filled the
   page column instead of the viewport (Vince, 2026-08-21: "it does not go full screen"). ztRte.escapeClip
   neutralises ancestor INLINE styles, but a transform supplied by a CSS ANIMATION outranks inline styles in
   the cascade — only an !important author rule beats it, so the wrapper is neutralised HERE the moment the
   overlay class exists, exactly like .zt-page-transition:has(.zt-site-editor). */
.zt-page-transition:has(.zt-rte-fullscreen) { animation: none !important; transform: none !important; perspective: none !important; }

/* ---- EXPAND-TO-EDIT (collapsed card mode) --------------------------------------------------------------
   The editor stays in the DOM (JS state + content survive) but hidden; the form shows the compact card
   instead — the SECURITY-CARD look (secc), values verbatim, so the two read as one family in a form. */
.zt-rte-expand-hidden { display: none; }

.zt-rte-expand {
	display: flex;
	align-items: center;
	gap: 10px;
	border: 1px solid #2e7d32;
	background-color: #f4f9f4;
	border-radius: 8px;
	padding: 10px 12px;
	max-width: 520px;
	cursor: pointer;
	transition: box-shadow 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}

.zt-rte-expand:hover {
	box-shadow: 0 2px 8px rgba(46, 125, 50, 0.18);
	border-color: #256628;
	background-color: #eef7ee;
}

.zt-rte-expand-icon {
	font-size: 1.25rem;
	line-height: 1;
	flex: 0 0 auto;
	color: #2e7d32;
}

.zt-rte-expand-body {
	flex: 1 1 auto;
	min-width: 0;
}

.zt-rte-expand-title {
	font-weight: 700;
	font-size: 0.98rem;
	line-height: 1.2;
}

.zt-rte-expand-desc {
	font-size: 0.82rem;
	line-height: 1.3;
	color: #5a6b5a;
	margin-top: 2px;
}

.zt-rte-expand-action { flex: 0 0 auto; }

/* "EDIT" PILL — outlined, fills green on hover of the card (the secc Manage pill). */
.zt-rte-expand-edit {
	display: inline-flex;
	align-items: center;
	gap: 5px;
	font-size: 0.8rem;
	font-weight: 700;
	color: #2e7d32;
	border: 1px solid #2e7d32;
	border-radius: 999px;
	padding: 3px 10px;
	background-color: #ffffff;
	white-space: nowrap;
	transition: background-color 0.12s ease, color 0.12s ease;
}

.zt-rte-expand:hover .zt-rte-expand-edit {
	background-color: #2e7d32;
	color: #ffffff;
}

/* ---- ALBUM HIDDEN FILE INPUTS -------------------------------------------------------------------------
   GLOBAL BY NECESSITY: the photo picker is an <InputFile> CHILD COMPONENT, and Blazor's scoped-CSS
   attribute NEVER lands on a child component's own markup — so the album's .razor.css copy of this rule
   can't reach it and the native "Choose Files / No file chosen" control rendered in the header.
   The <label> next to it is what the user actually clicks. */
.ca-hidden-file {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	border: 0;
}

/* ---- IMAGE TOOLS (click a picture → Delete / Resize chips at its bottom-right) -------------------------
   The chip bar is absolute inside the ROOT — position:relative makes the root the containing block in
   normal mode; in fullscreen the root is position:fixed, which is a containing block already. */
.zt-rte-root { position: relative; }

/* A PICTURE IS CLICKABLE (it selects) — say so with the cursor (Vince, 2026-08-13). */
.zt-rte-surface img { cursor: pointer; }

.zt-rte-surface img.zt-rte-img-selected {
	outline: 2px solid #1e88e5;
	outline-offset: 2px;
}

.zt-rte-imgtools {
	position: absolute;
	z-index: 30;
	display: flex;
	gap: 6px;
}

/* ICON-ONLY ROUND CHIPS ("just little round buttons" — Vince, 2026-08-13); the title= is the label. */
.zt-rte-imgtool {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 32px;
	height: 32px;
	font-size: 0.9rem;
	color: #ffffff;
	border: 0;
	border-radius: 50%;
	padding: 0;
	cursor: pointer;
	box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
	transition: background-color 0.12s ease;
}

/* HOUSE VERB COLOURS: resize/align = blue, rotate = purple (the move family), delete = red ·
   ok/accept = green, original size = blue, cancel = grey. */
.zt-rte-imgtool-resize { background-color: #1e88e5; }
.zt-rte-imgtool-resize:hover { background-color: #1976d2; }
.zt-rte-imgtool-rotate { background-color: #7b1fa2; }
.zt-rte-imgtool-rotate:hover { background-color: #6a1b9a; }
.zt-rte-imgtool-align { background-color: #1e88e5; }
.zt-rte-imgtool-align:hover { background-color: #1976d2; }
.zt-rte-imgtool-ok { background-color: #2e7d32; }
.zt-rte-imgtool-ok:hover { background-color: #256628; }
.zt-rte-imgtool-original { background-color: #1e88e5; }
.zt-rte-imgtool-original:hover { background-color: #1976d2; }
.zt-rte-imgtool-cancel { background-color: #757575; }
.zt-rte-imgtool-cancel:hover { background-color: #616161; }
.zt-rte-imgtool-delete { background-color: #e53935; }
.zt-rte-imgtool-delete:hover { background-color: #d32f2f; }

/* RESIZE HANDLES — a JS-built frame glued over the selected picture: 4 corner handles (aspect locked)
   + 4 edge handles (free stretch), with a grey size readout while dragging. The frame itself is
   pointer-transparent so the picture stays clickable; only the handles take the pointer. */
.zt-rte-imgbox {
	position: absolute;
	z-index: 29;
	pointer-events: none;
}

.zt-rte-imghandle {
	position: absolute;
	width: 12px;
	height: 12px;
	background-color: #ffffff;
	border: 2px solid #1e88e5;
	border-radius: 50%;
	pointer-events: auto;
	touch-action: none;
	box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}

/* CORNERS = CIRCLES (aspect locked). */
.zt-rte-imghandle-nw { left: -6px; top: -6px; cursor: nwse-resize; }
.zt-rte-imghandle-ne { right: -6px; top: -6px; cursor: nesw-resize; }
.zt-rte-imghandle-sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
.zt-rte-imghandle-se { right: -6px; bottom: -6px; cursor: nwse-resize; }

/* EDGES = PILL BARS (one-axis stretch) — a different shape so the two behaviours read apart (Vince). */
.zt-rte-imghandle-n, .zt-rte-imghandle-s {
	width: 24px;
	height: 9px;
	border-radius: 5px;
	left: calc(50% - 12px);
}

.zt-rte-imghandle-e, .zt-rte-imghandle-w {
	width: 9px;
	height: 24px;
	border-radius: 5px;
	top: calc(50% - 12px);
}

.zt-rte-imghandle-n { top: -5px; cursor: ns-resize; }
.zt-rte-imghandle-s { bottom: -5px; cursor: ns-resize; }
.zt-rte-imghandle-w { left: -5px; cursor: ew-resize; }
.zt-rte-imghandle-e { right: -5px; cursor: ew-resize; }

/* WHILE A HANDLE IS BEING DRAGGED the chip bar disappears — just the frame + the size readout. */
.zt-rte-img-dragging .zt-rte-imgtools { display: none; }

/* THE SIZE READOUT — grey w/ opacity (Vince), centred on the picture, only while dragging. */
.zt-rte-imgsize {
	position: absolute;
	left: 50%;
	top: 50%;
	transform: translate(-50%, -50%);
	background-color: rgba(85, 85, 85, 0.75);
	color: #ffffff;
	font-size: 0.85rem;
	font-weight: 700;
	padding: 4px 10px;
	border-radius: 6px;
	white-space: nowrap;
	pointer-events: none;
	display: none;
}

.zt-rte-imgbox.dragging .zt-rte-imgsize { display: block; }

/* THE FILL CHAIN — every wrapper between the root and the surface becomes a full-height flex column, so the
   surface ends up with all the space the toolbar and label do not use. min-height:0 on each is what actually
   lets a flex child SHRINK below its content and hand the room back. */
.zt-rte-fs,
.zt-rte-fs .zt-input-container,
.zt-rte-fs .zt-input,
.zt-rte-fs .zt-input-field,
.zt-rte-fs .zt-input-field-control,
.zt-rte-fs .zt-rte {
	display: flex; flex-direction: column;
	flex: 1 1 auto; min-height: 0;
	width: 100%; max-width: 100%;
	margin: 0;
}

/* THE LABEL BAR AND TOOLBAR KEEP THEIR OWN HEIGHT — only the surface grows. */
.zt-rte-fs .zt-input-header,
.zt-rte-fs .zt-rte-bar { flex: 0 0 auto; }

/* THE SURFACE TAKES THE REST. max-height and the inline min-height both have to go, or the 60vh cap and the
   MinHeightPx floor fight the flex sizing. */
.zt-rte-fs .zt-rte-surface,
.zt-rte-fs .zt-rte-code {
	flex: 1 1 auto;
	height: auto;
	min-height: 0 !important;      /* beats the inline style="min-height:NNNpx" */
	max-height: none;
}

.zt-rte-fullscreen .zt-rte { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); }

/* modal forms (link / media / icon) */
.zt-rte-form { display: flex; flex-direction: column; gap: 8px; }
.zt-rte-form > label { font-size: 0.8rem; font-weight: 600; color: #555; margin-top: 4px; }
.zt-rte-field {
	width: 100%;
	box-sizing: border-box;
	padding: 8px 10px;
	border: 1px solid #d4d9db;
	border-radius: 5px;
	font-size: 0.9rem;
	font-family: var(--zt-font, system-ui, sans-serif);
}
.zt-rte-check { display: flex; align-items: center; gap: 7px; font-size: 0.85rem; font-weight: 500; color: #444; margin-top: 2px; }
.zt-rte-check input { width: auto; }
.zt-rte-tabs { display: flex; gap: 4px; margin-bottom: 4px; }
.zt-rte-tab { flex: 1; padding: 7px; border: 1px solid #d4d9db; border-radius: 5px; background: #f4f6f7; cursor: pointer; font-size: 0.85rem; font-weight: 600; color: #555; }
.zt-rte-tab.active { background: var(--zeal-tiffany, #08bab5); border-color: var(--zeal-tiffany, #08bab5); color: #fff; }
.zt-rte-note { font-size: 0.8rem; color: #777; margin: 4px 0 0; }
.zt-rte-error { font-size: 0.82rem; color: var(--zeal-red, #c0392b); margin: 4px 0 0; }
.zt-rte-icongrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(46px, 1fr)); gap: 6px; height: 294px; overflow-y: auto; margin-top: 4px; }
.zt-rte-iconcell {
	display: flex; align-items: center; justify-content: center;
	height: 44px; border: 1px solid #eee; border-radius: 6px; background: #fff; cursor: pointer; font-size: 1.1rem; color: #444;
}
.zt-rte-iconcell:hover { background: #f0f7f7; border-color: var(--zeal-tiffany, #08bab5); color: var(--zeal-tiffany, #08bab5); }

/* ============================================================================
   ⭐ THE WARP ENGINE — FLYING FORWARD THROUGH SPACE. ONE COPY, TWO USERS.
   ============================================================================
   The LOADER (ZtStarfieldLoader) and the PAGE BACKDROP (ZtStarfield) are the
   same sky. They differ in two custom properties and nothing else:

     --zt-star-tile    the star tile. SMALLER = the same four stars repeat more
                       often = DENSER.
     --zt-star-flight  how long one star takes to travel from the vanishing
                       point to past the viewer. LOWER = FASTER.

   The backdrop sets them from the platform template (density + speed, per
   person, per academy). The loader sets its own, ~3x faster: a loader should
   feel urgent, a backdrop should not.

   ⚠️ THE FALLBACKS BELOW MIRROR THE SHIPPED DEFAULT — density 5 (a 145px tile)
   and speed 4 (9s), i.e. exactly what ZtPlatformTemplateSettings computes from
   ZtPlatformTemplateCatalog's defaults (updated 2026-08-15, when the shipped
   look went blue and full-density). They are a safety net only, since
   ZtStarfield always sets both inline — but if the catalog defaults move again,
   move these too, or an un-templated sky quietly flies at the old speed.

   ⚠️ THIS LIVES IN THE GLOBAL SHEET, NOT IN ZtStarfield's SCOPED CSS, and that
   is the point — scoped CSS cannot be shared, so a second caller would have had
   to copy the star gradients, the keyframes and the phone tuning. That is the
   exact mistake the reconnect overlay made with the lava.
   ---------------------------------------------------------------------------- */
.zt-warp {
	position: absolute;
	inset: 0;
	z-index: 0;                        /* ⚠️ NOT auto — see .zt-loader-face. Positioned siblings must paint above. */
	pointer-events: none;
	overflow: hidden;                  /* the flying layers scale past their box — never let them make a scrollbar */
	/* CONTAIN THE WHOLE THING. paint = nothing inside can affect anything outside, so the compositor can treat
	   this as one self-contained layer and never has to consider it while scrolling the page. */
	contain: layout paint style;
}

/* ONE FLYING LAYER.
   ⚠️ THE INSET AND THE SCALE RANGE ARE A BUDGET, NOT A TASTE. A scaling layer has to be RE-RASTERISED as it
   grows, and the cost is the layer's area. The first cut of the backdrop used inset:-50% (element = 2x the
   viewport each way) scaling to 3.4 — on a 375px phone that is a 1836 x 3900px surface, three of them, redrawn
   continuously. That was the "blinks real crazy on a mobile device".

   inset:-18% makes the element 1.36x its box, and the scale runs .8 -> 2.6. Coverage at the smallest scale is
   1.36 x .8 = 1.09, so the tiling still never shows an edge; peak area is down about 4x. The stars still grow
   3.25x across the cycle, which is what sells the forward motion. */
.zt-warp-layer {
	position: absolute;
	inset: -18%;
	background-repeat: repeat;
	/* Brighter, more DEFINED stars — a solid white core out to ~50% then a quick soft halo (not one long fade),
	   so each star reads as a crisp point with a gentle glow, like a real night sky. */
	background-image:
		radial-gradient(1.7px 1.7px at 20px 30px, #ffffff 0%, rgba(255, 255, 255, .95) 45%, transparent 100%),
		radial-gradient(1.3px 1.3px at 120px 80px, #ffffff 0%, rgba(255, 255, 255, .8) 50%, transparent 100%),
		radial-gradient(1.9px 1.9px at 200px 160px, #ffffff 0%, rgba(255, 255, 255, .9) 45%, transparent 100%),
		radial-gradient(1.2px 1.2px at 90px 200px, #ffffff 0%, rgba(255, 255, 255, .75) 50%, transparent 100%);
	background-size: var(--zt-star-tile, 145px) var(--zt-star-tile, 145px);
	transform-origin: 50% 50%;
	will-change: transform, opacity;   /* keep this on the compositor — it must never repaint the page */
	backface-visibility: hidden;       /* forces its own layer and stops the sub-pixel shimmer while scaling */
	animation: zt-warp var(--zt-star-flight, 9s) linear infinite;
	opacity: 0;                        /* the keyframes own the fade; 0 avoids a flash before the first frame */
}

/* THE THREE RUNS, EVENLY SPACED THROUGH ONE CYCLE. Negative delays start them already part-way through, so the
   sky is full on the very first frame instead of filling up over the first few seconds. On a LOADER that is not
   a nicety — the loader is often on screen for less than one cycle, so a sky that fills up gradually would be
   the only sky anyone ever sees. */
.zt-warp-layer-2 { animation-delay: calc(var(--zt-star-flight, 9s) * -0.333); }
.zt-warp-layer-3 { animation-delay: calc(var(--zt-star-flight, 9s) * -0.666); }

/* FLYING FORWARD = the field scaling up and out. Stars enter small and dim near the middle, brighten as they
   come at you, and fade as they sweep past the edge — the fade at both ends is what hides the loop. */
@keyframes zt-warp {
	0%   { transform: scale(.8); opacity: 0; }
	16%  { opacity: .8; }
	70%  { opacity: .8; }
	100% { transform: scale(2.6); opacity: 0; }
}

/* ── PHONES AND TABLETS: TWO LAYERS, NOT THREE ──────────────────────────────────────────────────────────────
   A touch device is doing this on a mobile GPU while also compositing the page, and it is the device where the
   backdrop was seen to break up. Dropping the third run takes another third off the raster budget; the
   remaining two are half a cycle apart instead of a third, which keeps the flow continuous. The effect is the
   same, it just costs what a phone can afford. */
@media (pointer: coarse), (max-width: 839px) {
	.zt-warp-layer-2 { animation-delay: calc(var(--zt-star-flight, 9s) * -0.5); }
	.zt-warp-layer-3 { display: none; }
}

/* REDUCED MOTION — a still night sky, not a blank one. The animation drives BOTH the transform and the opacity,
   so switching it off has to restore both or every star disappears. Only one layer is kept: two stacked
   identical fields at the same scale would just be one field at double brightness. */
@media (prefers-reduced-motion: reduce) {
	.zt-warp-layer { animation: none; transform: scale(1); opacity: .75; }
	.zt-warp-layer-2 { opacity: .5; transform: scale(1.6); }
	.zt-warp-layer-3 { display: none; }
}

/* ============================================================================
   ⭐ THE STARFIELD LOADER — the sky's loader settings, and the glowing face.
   ============================================================================
   THE SKY IS FASTER AND DENSER THAN THE BACKDROP'S. 3.4s vs the backdrop's 14s
   is not a tweak, it is the whole brief: "like it's coming at us very quickly".
   A loader borrowing the backdrop's calm ambient speed would read as the page
   sitting still.
   ---------------------------------------------------------------------------- */
.zt-warp-loader { --zt-star-flight: 3.4s; --zt-star-tile: 210px; }
/* FULL SCREEN: faster again, and a wider tile so the stars stay separate across a big surface rather than
   turning into static. A phone gets the same numbers — the tile is in px, so a small screen simply sees fewer
   stars travelling the same distance, which is the correct scaling for this effect. */
.zt-warp-loader.zt-warp-fast,
.zt-page-busy .zt-warp-loader,
.zt-login-busy .zt-warp-loader { --zt-star-flight: 2.8s; --zt-star-tile: 240px; }

/* THE FACE — the context's picture, cropped to a circle, glowing silver against the black.
   ⚠️ SILVER, NOT GOLD, AND THAT IS THE REASON THIS LOADER EXISTS (Vince, 2026-08-15): "this way I don't have to
   worry about the blob colors not matching the pictures. Some pictures don't look good with that color." A
   neutral white/silver halo flatters every photo, every academy logo and the Zeal mark equally — there is no
   picture it can clash with. ⛔ DO NOT TINT THIS. The moment it has a hue it has a picture it fights. */
.zt-loader-face {
	/* POSITIONED, WITH A REAL z-index — the sky is an absolutely-positioned sibling, and positioned elements
	   paint ABOVE in-flow content regardless of DOM order. Without this pair the stars fly over the picture. */
	position: relative;
	z-index: 1;
	flex: 0 0 auto;
	box-sizing: border-box;
	width: var(--zt-face-size, 120px);
	height: var(--zt-face-size, 120px);
	border-radius: 50%;
	background-color: #05070c;              /* backs a logo with transparency; invisible under a photo */
	/* ⚠️ WHOSE FACE: ZtShellLayout sets --zt-busy-logo from the ROUTE's context — academy logo on /connected/*,
	   the member's photo on /member/*, your own everywhere else. Custom properties inherit, so every loader on
	   the page is already correct and no call site passes a picture. It is set to `initial` when the context has
	   no picture, which is what makes var() fall back to the Zeal mark (an EMPTY value would substitute empty
	   and blank the background instead). The property keeps its --zt-busy- name deliberately: ZtShellLayout and
	   zt-ui.js both write it, and renaming a wire format to match a component's new name is how live features
	   break for no benefit. */
	/* ⚠️ TWO LAYERS, AND THE SECOND IS A SAFETY NET — NOT A DUPLICATE. Every face url is an Azure blob link
	   carrying a 24-HOUR SAS token, so any stored or cached one WILL eventually be dead — and a dead
	   background-image does not fall back to anything, it simply fails to paint and leaves a DARK HOLE where a
	   face should be. The Zeal mark as the BOTTOM layer makes an expired url degrade to the brand instead of to
	   nothing, anywhere in the app. Valid var → the photo sits on top at `cover` and hides the mark entirely;
	   `initial` (guaranteed-invalid) → the var() fallback substitutes the mark and both layers are the mark. */
	background-image: var(--zt-busy-logo, url("../images/zeal-logo-round.png")), url("../images/zeal-logo-round.png");
	background-size: cover;                 /* fills the circle and the circle crops it — the avatar treatment */
	background-position: center;
	background-repeat: no-repeat;
	/* THE DEEP CORONA — ONE smooth still glow, five halving layers so the light decays in a single continuous
	   curve (two separate light sources with a dip between them is what read as a "double halo"). STILL on
	   purpose: the motion in this loader is the sheen (below), not a pulse. Sized off the face so it scales
	   whole. Approved as "shimmer in a deep corona", Vince 2026-08-21. */
	box-shadow:
		0 0 0 2px rgba(255, 255, 255, 0.32),
		0 0 calc(var(--zt-face-size, 120px) * 0.075) rgba(255, 255, 255, 0.38),
		0 0 calc(var(--zt-face-size, 120px) * 0.175) rgba(255, 255, 255, 0.22),
		0 0 calc(var(--zt-face-size, 120px) * 0.344) rgba(226, 232, 240, 0.12),
		0 0 calc(var(--zt-face-size, 120px) * 0.594) rgba(214, 226, 240, 0.06);
}

/* FULL SCREEN — the big picture, capped to the SHORT side of the viewport so a phone gets the same loader
   scaled down rather than one that runs off the edges (~127px on a 375px phone, 160px on any desktop). The two
   fixed HOSTS set the same value on .zt-busy-inner instead, because there the tier belongs to the host; this
   class is for a caller that owns its own overlay, which today means the reconnect screen. */
.zt-loader-face-full { --zt-face-size: min(160px, 34vmin); }

/* THE SHEEN — a slow-rotating, blurred silver highlight riding a MASKED ring at the face's rim: moonlight
   sweeping the edge of the picture. This replaced the breathing bloom on 2026-08-21 after three rounds
   ("Looney Tunes" → "double halo" → "I like #3"): the approved look is the STILL deep corona above with this
   ONE moving element swimming through it.

   ⚠️ WHY A MASK AND NOT z-index: the face has z-index:1 and is therefore ITS OWN STACKING CONTEXT, and inside
   a stacking context a negative-z-index child paints ABOVE the element's own background — the old
   centre-bright bloom was laying 42% white straight over the photo ("it's making the picture itself
   lighter"). No z-index can put a child under its own parent's background; the mask is what keeps the paint
   off the picture. The band deliberately begins a hair INSIDE the rim (62% of this box's half vs the photo's
   ~69%) so the blurred light kisses the very edge of the circle — that wrap is what made the lineup version
   read as light rather than a ring; strictly outside-the-rim looked detached and thin.

   ⚠️ TRANSFORM-ONLY ANIMATION — the blur is rasterised once and the layer just rotates on the compositor. Do
   not animate the gradient, the mask, or the filter. */
.zt-loader-face::before {
	content: "";
	position: absolute;
	inset: -22%;
	border-radius: 50%;
	background: conic-gradient(from 0deg,
		rgba(255, 255, 255, 0) 0deg, rgba(255, 255, 255, 0.55) 40deg, rgba(255, 255, 255, 0.06) 95deg,
		rgba(255, 255, 255, 0.40) 165deg, rgba(255, 255, 255, 0) 225deg,
		rgba(255, 255, 255, 0.48) 300deg, rgba(255, 255, 255, 0) 360deg);
	-webkit-mask: radial-gradient(circle, transparent 53%, #000 62%, #000 76%, transparent 88%);
	mask: radial-gradient(circle, transparent 53%, #000 62%, #000 76%, transparent 88%);
	filter: blur(5px);
	will-change: transform;
	animation: zt-loader-sheen-spin 7s linear infinite;
}

@keyframes zt-loader-sheen-spin {
	to { transform: rotate(360deg); }
}

/* REDUCED MOTION — the corona alone. A frozen sheen would read as a lopsided smudge of light, so it goes
   entirely rather than merely stopping. */
@media (prefers-reduced-motion: reduce) {
	.zt-loader-face::before { display: none; }
}

/* ============================================================================
   THE LAVA LOADER — ONE STAGE, ONE SET OF BLOBS, ONE FACE RATIO, WHOLE APP.
   ============================================================================
   ⚠️ SET ASIDE, NOT DEAD (Vince, 2026-08-15: "in case I hate it, we'll come back
   to the lava"). Nothing renders these rules while ZtLoaderStyle.Current is
   Starfield — flip that one value and the whole app wears the lava again, this
   block included. Do not delete it, and keep it working.
   ----------------------------------------------------------------------------
   ⚠️ THIS BLOCK IS THE ONLY COPY. IT USED TO BE TWO (Vince, 2026-08-15): these
   .zt-busy-* rules and a hand-tuned duplicate in ReconnectModal.razor.css. They
   drifted, and the difference was visible in one screenshot each — the reconnect
   mark floating in a wide gold halo on a 300px stage, the page loader's picture
   nearly filling a 170px one. Both are now <ZtLava>, styled here.

   THE GEOMETRY IS TWO NUMBERS, and everything else is a % of them:
     --lava      THE STAGE. The whole loader scales off it. Set by the TIER
                 (full-screen host or container query), never by a call site.
     --zt-face   THE FACE'S SHARE OF THE STAGE — 44%, which is the reconnect
                 overlay's 132px inside its 300px, i.e. the proportion Vince
                 pointed at as correct. The remaining 56% is the gold band the
                 goo lives in. ⚠️ THIS IS THE NUMBER THAT WENT WRONG: it walked
                 44% -> 58% -> 80% chasing "make the picture bigger" on the
                 in-card loader alone, and at 80% the picture swallowed the goo
                 (the blobs are 85%/92% wide) so the loader stopped reading as a
                 loader. Change it HERE, once, or not at all.
   ---------------------------------------------------------------------------- */
.zt-lava {
	position: relative;
	flex: 0 0 auto;
	width: var(--lava, 170px);
	height: var(--lava, 170px);
	--zt-face: 44%;
}

/* THE FULL-SCREEN TIER — the reconnect overlay's 300px stage. Capped to the SHORT side of the viewport
   (68vmin) so a phone gets the same loader scaled down, never one that runs off the edges: 375px-wide
   phone -> 255px, anything desktop-sized -> the full 300px. The page loader and the login loader are the
   same tier and pick it up from their fixed hosts, so no call site has to remember to ask for it. */
.zt-lava-full,
.zt-page-busy .zt-lava,
.zt-login-busy .zt-lava { --lava: min(300px, 68vmin); }

/* blur melts the blobs into one another; scaled off the stage so it holds at every size */
.zt-lava-goo-layer { position: absolute; inset: 0; filter: blur(calc(var(--lava) * 0.035)); }
.zt-lava-goo {
	position: absolute;
	top: 50%;
	left: 50%;
	transform-origin: center;
	mix-blend-mode: screen;
	border-radius: 40% 60% 70% 30% / 50% 60% 30% 70%;
	animation: zt-lava-goo linear infinite;
}
/* hot cores that reach the face (sizes as % of --lava so the whole loader scales together) */
.zt-lava-goo-1 { width: 85%; height: 90%; background: radial-gradient(circle, #d4af37 38%, transparent 74%); animation-duration: 8s;  opacity: 0.85; }
.zt-lava-goo-2 { width: 73%; height: 78%; background: radial-gradient(circle, #ff4500 34%, transparent 72%); animation-duration: 12s; animation-direction: reverse; opacity: 0.65; }
.zt-lava-goo-3 { width: 92%; height: 80%; background: radial-gradient(circle, #c8920a 38%, transparent 74%); animation-duration: 15s; opacity: 0.8; }
.zt-lava-goo-4 { width: 68%; height: 72%; background: radial-gradient(circle, #ff7a00 32%, transparent 70%); animation-duration: 6s;  animation-direction: reverse; opacity: 0.6; }
.zt-lava-goo-5 { width: 80%; height: 80%; background: radial-gradient(circle, #ffd24a, #ffae00); animation-duration: 20s; opacity: 0.5; }
/* rotate + morph (melting goo) — smooth, no positional wander */
@keyframes zt-lava-goo {
	0%   { transform: translate(-50%, -50%) rotate(0deg) scale(1);      border-radius: 40% 60% 70% 30% / 50% 60% 30% 70%; }
	33%  { transform: translate(-50%, -50%) rotate(120deg) scale(1.14); border-radius: 70% 30% 50% 50% / 30% 30% 70% 70%; }
	66%  { transform: translate(-50%, -50%) rotate(240deg) scale(0.9);  border-radius: 30% 70% 70% 30% / 50% 40% 70% 40%; }
	100% { transform: translate(-50%, -50%) rotate(360deg) scale(1);    border-radius: 40% 60% 70% 30% / 50% 60% 30% 70%; }
}

/* THE FACE — whoever's picture the current context says it is. */
.zt-lava-face {
	position: absolute;
	top: 50%;
	left: 50%;
	transform: translate(-50%, -50%);
	z-index: 5;
	/* NO GLOBAL border-box RESET IN THIS SHEET — say it here so any future padding/border eats IN, not out. */
	box-sizing: border-box;
	width: var(--zt-face);
	height: var(--zt-face);
	border-radius: 50%;
	background-color: rgba(0, 0, 0, 0.85);   /* backs a logo with transparency; invisible under a photo */
	/* ⚠️ THE FACE IN THE LOADER IS THE CONTEXT YOU ARE IN (Vince, 2026-08-15) — the academy's logo on a
	   connected page, the member's photo on a member page, your own on your own pages, and the Zeal mark only
	   when that context genuinely has no picture. ZtShellLayout sets --zt-busy-logo from the resolved context;
	   EVERY lava in the app inherits it, so this is one variable rather than a parameter threaded through
	   nineteen call sites. It is set to `initial` when there is no context picture, which is what makes var()
	   fall back to the Zeal mark (an EMPTY value would substitute empty and blank the background instead).
	   The reconnect overlay is rendered OUTSIDE the shell, so it inherits nothing and always shows the Zeal
	   mark — which is right: the thing you lost the connection to is Zeal, not the page you were on. */
	/* ⚠️ TWO LAYERS, AND THE SECOND IS A SAFETY NET — NOT A DUPLICATE. Every face url is an Azure blob link
	   carrying a 24-HOUR SAS token, so any stored or cached one WILL eventually be dead — and a dead
	   background-image does not fall back to anything, it simply fails to paint and leaves a DARK HOLE where a
	   face should be. The Zeal mark as the BOTTOM layer makes an expired url degrade to the brand instead of to
	   nothing, anywhere in the app. Valid var → the photo sits on top at `cover` and hides the mark entirely;
	   `initial` (guaranteed-invalid) → the var() fallback substitutes the mark and both layers are the mark. */
	background-image: var(--zt-busy-logo, url("../images/zeal-logo-round.png")), url("../images/zeal-logo-round.png");
	/* ⚠️ COVER, ALWAYS — THE PICTURE FILLS THE CIRCLE AND THE CIRCLE CROPS IT, exactly like the avatar on the
	   connected-account and user home headers. An inset percentage was tried and is what made a cropped-square
	   logo read as a small badge on a rounded plate instead of a profile picture. */
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
	/* THE ONLY EDGE TREATMENT — a soft dark halo, tight and scaled, so the face separates from the gold
	   without a line drawn round it. A white ring was tried at the old 80% size and read as a border. */
	box-shadow: 0 0 calc(var(--lava, 100px) * 0.07) rgba(0, 0, 0, 0.5);
}

@media (prefers-reduced-motion: reduce) {
	.zt-lava-goo { animation: none; }
}

/* ---- BUSY / WORKING OVERLAY — the dimming layer + caption AROUND a ZtLava --- */
/* Drop <ZtBusy Busy="..."/> inside any position:relative container. Pure CSS, cross-platform. */
.zt-busy-overlay {
	position: absolute;
	inset: 0;
	z-index: 50;
	display: flex;
	align-items: center;
	justify-content: center;
	overflow: hidden;
	border-radius: inherit;            /* match the rounded card/section corners */
	/* DEEP SPACE, because the loader flying over it is a starfield — the same base the page backdrop uses, so
	   the card you are waiting on reads as a window onto the same sky. Still slightly translucent + frosted in
	   a card, so you can see (dimmed) which card is working. */
	background: radial-gradient(circle at center, rgba(10, 10, 15, 0.90) 0%, rgba(4, 4, 8, 0.97) 92%);
	-webkit-backdrop-filter: blur(3px);
	backdrop-filter: blur(3px);
	container-type: size;              /* the inner wrapper queries this for square-vs-wide layout */
}

/* ⚠️ THE BACKDROP FOLLOWS THE LOADER THAT ACTUALLY RENDERED. Flip ZtLoaderStyle.Current back to Lava and this
   restores the warm gold-tinted vignette the blobs were tuned against — no second switch to remember, and no
   way to end up with a gold vignette behind a starfield. :has() is doing exactly what it is for: styling a
   parent by what its child turned out to be. */
.zt-busy-overlay:has(.zt-lava) {
	background: radial-gradient(circle at center, rgba(12, 10, 6, 0.76) 0%, rgba(0, 0, 0, 0.93) 92%);
}

/* DEFAULT (square-ish): lava stacked above the caption; lava scales to the smaller dimension. */
.zt-busy-inner {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: clamp(8px, 5cqmin, 16px);
	padding: 6%;
	box-sizing: border-box;
	--lava: clamp(54px, 52cqmin, 170px);
	/* THE FACE IS SMALLER THAN THE LAVA STAGE WAS, and it has to be: the lava's 170px box was mostly goo with a
	   44% picture inside it, whereas this IS the picture and it needs room around it for the halo to bloom into
	   (the glow reaches ~1.5x the face). Sized off cqmin so it tracks the card it covers. */
	--zt-face-size: clamp(40px, 34cqmin, 118px);
}

/* WIDE (≥ 2:1): picture on the LEFT, caption on the RIGHT, scaled to the container height. */
@container (min-aspect-ratio: 2 / 1) {
	.zt-busy-inner {
		flex-direction: row;
		gap: clamp(6px, 2cqw, 18px);     /* a little air, so the halo does not bleed into the first letter */
		padding-inline: clamp(10px, 4cqw, 40px);
		--lava: clamp(46px, 70cqh, 150px);
		--zt-face-size: clamp(34px, 44cqh, 100px);
	}
	.zt-busy-inner .zt-busy-message { text-align: left; max-width: 62cqw; font-size: 0.98rem; }
}

/* POSITIONED FOR THE SAME REASON THE FACE IS — the starfield is an absolutely-positioned sibling and would
   otherwise paint straight over the caption. */
.zt-busy-message {
	position: relative;
	z-index: 1;
	color: #f4f4f4;
	font-size: 0.85rem;
	font-weight: 500;
	letter-spacing: 0.02em;
	text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
}

/* ---------------------------------------------------------------------------
   THE FULL-SCREEN TIER — MAKE THE PAGE LOADER THE RECONNECT OVERLAY.
   ---------------------------------------------------------------------------
   The two fixed hosts (#zt-page-busy, .zt-login-busy) are the ONLY places a
   ZtBusy covers the whole viewport, and a viewport is wider than 2:1 on any
   desktop — so the container query above was putting the page loader in its
   SIDE-BY-SIDE layout (small lava left, caption right) while the reconnect
   overlay stacked a big one over centred text. Same app, two loaders. The tier
   is decided HERE by the host, not by a call site, so #zt-page-busy keeps its
   plain <ZtBusy> and every page gets this for free.
   --------------------------------------------------------------------------- */
.zt-page-busy .zt-busy-inner,
.zt-login-busy .zt-busy-inner {
	flex-direction: column;
	/* ⚠️ THE GAP IS MEASURED AGAINST THE HALO, NOT THE PICTURE. The bloom reaches about a quarter of the face's
	   width past its edge (~37px at the 160px size), so the old 0.25rem the lava used would have put the caption
	   inside the glow. 1.75rem clears the bright part and leaves the text sitting in the soft tail, which is
	   what makes it read as lit rather than washed out. */
	gap: 1.75rem;
	padding: 0 1.5rem;                  /* keep the caption off the edges on a narrow phone */
	/* THE BIG PICTURE. Capped to the SHORT side of the viewport so a phone gets the same loader scaled down,
	   never one that runs off the edges: on a 375px-wide phone that is ~127px, on any desktop the full 160px. */
	--zt-face-size: min(160px, 34vmin);
}

/* ⚠️ FULL SCREEN IS OPAQUE — YOU ARE IN SPACE, NOT LOOKING AT A FROSTED PAGE. The blurred-page look is right
   for a CARD (it says "this card is working, the rest is fine") and wrong for the whole screen, where a half-
   visible page behind the stars just muddies both. This also settles the last difference between the page
   loader and the reconnect screen, which was always opaque. */
/* ⚠️ THE :has(.zt-warp) GUARD IS NOT DECORATION — without it this rule ties on specificity with the
   :has(.zt-lava) rule above and, being later in the file, would win. The full-page lava would then lose its
   warm vignette the moment someone flipped the switch back. Each background now names the loader it belongs
   to, so neither can steal the other's. */
.zt-page-busy .zt-busy-overlay:has(.zt-warp),
.zt-login-busy .zt-busy-overlay:has(.zt-warp) {
	background: radial-gradient(circle at center, #0a0a0f 0%, #050508 88%);
	-webkit-backdrop-filter: none;
	backdrop-filter: none;
}
/* The reconnect overlay's status text, to the pixel. ⚠️ THE EXTRA .zt-busy-inner IS DELIBERATE — the wide
   branch of the container query targets `.zt-busy-inner .zt-busy-message` and would otherwise tie on
   specificity, leaving this rule winning only by source order. */
.zt-page-busy .zt-busy-inner .zt-busy-message,
.zt-login-busy .zt-busy-inner .zt-busy-message {
	text-align: center;
	max-width: 24rem;
	font-size: 1.05rem;
	font-weight: 500;
	line-height: 1.5;
	letter-spacing: 0.2px;
	color: #ffffff;
}

/* ---- CONTENT ITEMS (labelled values, like V1 ZtContentItem) ------------ */
/* GLOBAL consistent vertical rhythm between stacked ZtContentItems — IDENTICAL whether they sit inside a
   ZtContentItems wrapper or bare in a card/section body. ONE mechanism (adjacent-sibling margin) so the spacing
   is the same everywhere (bare passkey/phone/address rows used to have none; the wrapper used a 5px flex gap). */
/* A HAIRLINE of row gap — Vince dialled this in against the live Revenue grid. It reads as nothing on stacks
   that already space themselves via --zt-item-gap, but it is the ONLY spacing inside .zt-figure-grid (which
   zeroes the per-item margin), and it is what keeps a dense column of figures from looking crushed. */
.zt-content-items { display: flex; flex-direction: column; gap: 0.05rem; }
.zt-content-item { display: flex; align-items: center; gap: 10px; }
.zt-content-item + .zt-content-item { margin-top: var(--zt-item-gap); }
/* GRID-laid content items (the ztdc detail grid + membership) space their rows via the grid's OWN row-gap (also
   --zt-item-gap) — exclude them from the adjacent margin so 2-col grids don't misalign or double-space. */
.ztdc-details .zt-content-item + .zt-content-item,
.zt-membership-view-grid .zt-content-item + .zt-content-item,
.zt-membership-vcol .zt-content-item + .zt-content-item { margin-top: 0; }
.zt-content-item-icon { color: var(--zeal-tiffany); flex-shrink: 0; font-size: 1.05rem; }
.zt-content-item-inline { display: flex; align-items: baseline; gap: 5px; min-width: 0; flex-wrap: nowrap; }
/* GREEN tone (e.g. an Active status) — mirrors the danger variant. */
.zt-content-item-ok .zt-content-item-icon { color: var(--zeal-green); }
.zt-content-item-ok .zt-content-item-value { color: var(--zeal-green); font-weight: 700; }
/* CLICKABLE variant — the item renders as a <button>; strip all button chrome, keep the flex row. */
.zt-content-item-clickable { background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; }
.zt-content-item-label { color: #666; white-space: nowrap; }
/* VALUE colour = medium-grey (#666), NOT dark #333 — matches the legacy (its value inherits the section's
   $zealMediumGrey). GLOBAL: every ZtContentItem value across the app reads a touch greyer, not black. */
.zt-content-item-value { font-weight: 700; color: var(--zeal-medium-grey); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* DANGER tone — icon + label + value all red (e.g. an overdue status). */
.zt-content-item-danger .zt-content-item-icon,
.zt-content-item-danger .zt-content-item-label,
.zt-content-item-danger .zt-content-item-value { color: var(--zeal-red); }

/* GOLD TONE — "Archived" and anything else that is RETIRED rather than WRONG (Vince, 2026-08-05).
   --zt-gold-accent, not --zeal-gold: the button gold all but disappears on a near-white panel, which is why
   the palette carries this deeper shade for exactly this job. */
.zt-content-item-warn .zt-content-item-icon,
.zt-content-item-warn .zt-content-item-label,
.zt-content-item-warn .zt-content-item-value { color: var(--zt-gold-accent); }
/* the inline Pay button living in a content-item value (e.g. next to "Overdue") — keep the value from clipping
   it with its ellipsis. It is a ZtIconOnlyButton as of 2026-08-21 — a BARE GLYPH: it went labelled
   ZtSmallButton -> boxed ZtIconButton -> bare, because it sits inside the red OVERDUE! line and a filled box
   competed with the text it belongs to. The chrome is the control's either way; .zt-pay-btn is only spacing. */
.zt-content-item-value:has(.zt-pay-btn) { overflow: visible; }

/* PHONE LINK — a tel: call link (the number, as a blue link) + a small sms: text icon beside it. */
.zt-phone { display: inline-flex; align-items: center; gap: 0.4rem; min-width: 0; }
.zt-phone-call { color: var(--zeal-blue, #1b6ec2); text-decoration: none; font-weight: 700; white-space: nowrap; }
.zt-phone-call:hover { text-decoration: underline; }
.zt-phone-sms { display: inline-flex; align-items: center; color: var(--zeal-blue, #1b6ec2); font-size: 0.85em; flex: 0 0 auto; }
.zt-phone-sms:hover { color: var(--zeal-tiffany); }
/* email as a mailto: link — same treatment as the phone call link, ellipsis-truncating a long address. */
.zt-email-link { color: var(--zeal-blue, #1b6ec2); text-decoration: none; font-weight: 700; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-email-link:hover { text-decoration: underline; }
.zt-pay-btn { margin-left: 0.35rem; }   /* inline spacing ONLY — the chrome is ZtIconOnlyButton's, never restyled here */

/* ── WAVE EDITOR — reusable full-screen OVERLAY. When one is open the app shell + nav are HIDDEN (body.zt-wave-open),
   so ONLY the wavy backdrop and the editor panel show; closing restores everything. Header = identity/title + a
   top-right close; footer = the actions. The panel renders in the self-hosted UI font (set explicitly, like modals). ── */
.zt-wave-overlay {
	position: fixed; left: 0; right: 0; z-index: 9500; box-sizing: border-box;
	top: var(--vv-top, 0px);
	height: 100vh; height: 100dvh; height: var(--vv-height, 100dvh);
	display: flex; align-items: center; justify-content: center;
	/* SYMMETRIC padding → the panel centres on the TRUE viewport centre. (No navbar reservation: the nav is HIDDEN
	   while a wave editor is open, so reserving its height would push the panel off-centre.) */
	padding: 1.5rem 1rem;
	background: transparent;   /* the shell behind is hidden, so the wavy backdrop shows straight through */
	/* a LOCAL wave editor (membership / rank / attendance) lives INSIDE .zt-shell-page, which body.zt-wave-open sets
	   to visibility:hidden — re-assert visibility here so the overlay shows even though its ancestor is hidden. */
	visibility: visible;
	/* ⚠️ POINTER EVENTS TOO: a NESTED overlay (a section's editor hosted INSIDE another wave's body — member
	   details hosting the phone/address sections) INHERITS the outer overlay's .zt-wave-under pointer-events:none,
	   and every click in the inner editor silently dies — no Cancel, no Save, no X. Sibling overlays never hit
	   this (pointer-events isn't applied to them), which is why the refund → recharge chain worked while the
	   nested case didn't. Re-asserting auto here makes the top overlay clickable wherever it lives in the DOM. */
	pointer-events: auto;
}
/* STACKED EDITORS — everything below the TOP of the ztWave stack is hidden. An editor opened over another (the
   refund editor handing off to recharge) used to leave the one underneath fully rendered and showing through,
   which read as two overlapping dialogs. Hidden rather than unmounted, so the one beneath keeps ALL its state and
   is simply revealed again when the top one closes — that is what makes the back-chain work at any depth without
   a single editor knowing about any other. Two classes, so it beats the `visibility: visible` above. */
.zt-wave-overlay.zt-wave-under { visibility: hidden; pointer-events: none; }
.zt-wave-panel {
	width: min(760px, 96vw); box-sizing: border-box;
	/* TALLEST possible panel that still leaves the symmetric 1.5rem gap top + bottom (so long forms get more room). */
	max-height: calc(100vh - 3rem);
	max-height: calc(var(--vv-height, 100dvh) - 3rem);
	display: flex; flex-direction: column;
	background: var(--zeal-white); border-radius: 12px; overflow: hidden;
	box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
	font-family: var(--zt-font);
	/* The panel declares its OWN text colour (like its font): the app shell sits on the dark backdrop and sets white
	   text, which would otherwise be inherited onto this white panel and render invisible. */
	color: var(--zeal-dark-grey);
	--zt-wave-gap: 0.85rem;   /* the ONE consistent vertical gap between everything in the body — tune here only */
	/* ⛔ THE PANEL'S INSET — ONE PAIR OF NUMBERS FOR ALL THREE PANES (header, body, footer). TUNE HERE ONLY.
	   Vince, 2026-08-22: "I keep asking you to make sure things are centered vertically and that our padding is
	   consistent… look at the padding above the email address, look at the padding below having trouble signing
	   in. That needs to be centered."

	   HE WAS RIGHT, AND IT WAS NOT ONE GAP — IT WAS FOUR DIFFERENT NUMBERS hand-picked across three rules:
	     header  0.95rem vertical, 1.15rem left
	     body    1.15rem TOP but 1.30rem BOTTOM, 1.2rem sides   <- the gap he could see
	     footer  1.15rem vertical, 1.2rem sides
	   So the body was bottom-heavy by 0.15rem, the header sat 0.2rem tighter than everything under it, and the
	   header's left edge missed the body's by 0.05rem. Every one of those was somebody typing a number that
	   looked right on the screen they happened to be building.

	   ⛔ SO: NO PANE MAY DECLARE ITS OWN PADDING NUMBERS. Use these two variables. Vertical padding is ONE value
	   applied to top AND bottom — never a three-value shorthand, which is the exact shape that lets top and
	   bottom drift apart without anyone noticing. */
	--zt-wave-pad-y: 1.15rem;
	--zt-wave-pad-x: 1.2rem;
	/* Extra right-hand room in the HEADER ONLY, so the close (X) never sits on the title. It is an ADDITION to
	   the shared inset (calc), not a replacement for it — the header's own left/top/bottom stay in the system. */
	--zt-wave-close-room: 2.2rem;
}
/* while a wave editor is open, hide EVERYTHING except the wavy backdrop + the editor (Vince: just waves + the form).
   NOTE the overlay itself re-asserts `visibility: visible` (above), so a LOCAL wave editor rendered inside the page
   still shows — only its surroundings go hidden. */
body.zt-wave-open .zt-shell-page,
body.zt-wave-open .zt-navbar { visibility: hidden; }
/* SAFETY NET — a transform/animation on the page-transition wrapper makes it the containing block for position:fixed
   descendants, which anchors an in-page overlay to the scrolled content instead of the viewport (the "not centered"
   bug). The JS strips .zt-pt-play after the animation, but while ANY overlay is open we also neutralize it here. */
body.zt-wave-open .zt-page-transition,
body.zt-noscroll .zt-page-transition { animation: none !important; transform: none !important; perspective: none !important; }
/* HEADER — identity/title on the left, the close (X) pinned top-right with breathing room. Neutral grey by default;
   the global "happy" green when Happy. */
.zt-wave-header { position: relative; flex: 0 0 auto; display: flex; align-items: center; gap: 1rem; padding: var(--zt-wave-pad-y) calc(var(--zt-wave-pad-x) + var(--zt-wave-close-room)) var(--zt-wave-pad-y) var(--zt-wave-pad-x); background: #f4f4f4; border-bottom: 1px solid #e4e4e4; }
.zt-wave-happy .zt-wave-header { background: var(--zt-happy-bg); border-bottom-color: var(--zt-happy-bd); }
/* WARN — the destructive counterpart (remove a payment method). Same yellow as the warning section headers, so
   "this one is dangerous" reads the same everywhere. */
.zt-wave-warn .zt-wave-header { background: var(--zt-warn-bg); border-bottom-color: var(--zt-warn-bd); }
/* THE HEADER'S FALLBACK AVATAR TAKES THE PANEL'S TONE. The ZtMediaObject default is a dark grey plate (#444) —
   right for a missing member PHOTO, wrong here, where the "avatar" is a subject icon on a happy or cautionary
   panel and the grey disc reads as an error. Scoped to .zt-wave-head so only wave-editor headers change. */
.zt-wave-happy .zt-wave-head .ztmo-media { background: var(--zeal-green); color: var(--zeal-white); }
.zt-wave-warn .zt-wave-head .ztmo-media { background: var(--zeal-gold); color: var(--zeal-dark-grey); }
.zt-wave-neutral .zt-wave-head .ztmo-media { background: var(--zeal-green-header); color: var(--zeal-white); }
/* ...UNLESS THE HEADER IMAGE IS A LOGO (ZtWaveEditor HeaderLogo). A brand mark brings its own colours, so the
   tone-coloured plate fights it — the disc goes neutral grey and the mark is CONTAINED, not cropped like a photo.
   ⚠️ THE SELECTOR IS DELIBERATELY HEAVIER THAN THE TONE RULES ABOVE (4 classes vs 3). Source order alone would
   NOT win: ".zt-wave-happy .zt-wave-head .ztmo-media" is 0,3,0, so a two-class rule loses no matter where it sits.
   A LOGO WEARS A BIGGER DISC WITH A TIGHTER RING than a photo. A face wants the crop and room around it; a brand
   mark wants to be BIG and to fill its disc — the 7px ring of grey was reading as the subject rather than the
   logo (Vince: "it's way too big around there" ... "make that zeal logo way bigger"). So: larger disc, 2px
   padding. Redefining --zt-media-avatar HERE works because ZtMediaObject sets it inline on the PARENT .ztmo —
   an own-element declaration beats an inherited one. */
/* THE DISC BEHIND A BRAND MARK IS ALMOST WHITE, not grey (Vince, 2026-08-16: "the gray border or gray circle we
   have behind that is kind of a weird color. Let's make that much lighter"). #dcdcdc read as a dirty shadow
   around the yellow Zeal mark; a near-white plate lets the logo be the only coloured thing in the header. The
   tone-coloured disc is still what a fallback ICON gets — this rule only applies when HeaderLogo is set. */
.zt-wave-panel .zt-wave-head.zt-wave-head-logo .ztmo-media {
	background: #f7f8fa;
	color: var(--zeal-grey);
	--zt-media-avatar: 76px;
	border-width: 2px;
}
.zt-wave-panel .zt-wave-head.zt-wave-head-logo .ztmo-media-img { object-fit: contain; padding: 2px; background: #f7f8fa; }
/* NEUTRAL — informational work (fill this in, review this, submit this). Yellow was being used for editors that
   carry no danger at all, which spends the warning colour on things that are not warnings. */
.zt-wave-neutral .zt-wave-header { background: var(--zt-neutral-bg); border-bottom-color: var(--zt-neutral-bd); }
/* ERROR — a dispute is being answered. See the --zt-error-* note for why this is a wash, not an alarm. */
.zt-wave-error .zt-wave-header { background: var(--zt-error-bg); border-bottom-color: var(--zt-error-bd); }
.zt-wave-header-content { flex: 1 1 auto; min-width: 0; }
/* THE CLOSE (X) IS INSET BY THE SAME TOKEN AS EVERY PANE — its top-right corner sits exactly --zt-wave-pad-y /
   --zt-wave-pad-x from the header's corner, so it lines up with the body and footer edges below it.
   ⛔ NOT top:50% + translateY(-50%): .ztbtn:active already sets transform, so a centred X would JUMP to the
   corner on every click. Inset beats centring when a shared rule owns the other axis. */
.zt-wave-close { position: absolute; top: var(--zt-wave-pad-y); right: var(--zt-wave-pad-x); }
/* HEADER ACTIONS — docked immediately LEFT of the close X: ONE uniform 4px gap across the whole cluster
   (Edit ↔ Home ↔ Close), which needs deterministic widths — every button in the row is pinned to the same
   2rem square the close X gets, and the container's right offset is computed from exactly that. */
.zt-wave-close.ztbtn.ztbtn-icon { width: 2rem; }
.zt-wave-header-actions { position: absolute; top: var(--zt-wave-pad-y); right: calc(var(--zt-wave-pad-x) + 2rem + 4px); display: flex; align-items: center; gap: 4px; }
.zt-wave-header-actions .ztbtn.ztbtn-icon { width: 2rem; }
/* BODY — scrolls; overscroll contained so it doesn't chain to the page behind. REUSABLE CONSISTENT SPACING: the body
   AND any <form> inside it stack their children on the SAME gap (--zt-wave-gap), and EVERY form control's own vertical
   margin is zeroed — so ANY form dropped into a wave editor reads consistently with NO per-form spacing hacks. */
.zt-wave-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--zt-wave-pad-y) var(--zt-wave-pad-x); display: flex; flex-direction: column; gap: var(--zt-wave-gap); }
.zt-wave-body form,
.zt-wave-look form { display: flex; flex-direction: column; gap: var(--zt-wave-gap); margin: 0; }
/* ⚠️ NOTHING IN THE BODY MAY SHRINK. .zt-wave-body is a flex COLUMN, and a flex item's default flex-shrink:1
   means that once the content is taller than the panel every child gets SQUASHED to fit instead of the body
   scrolling. On a short form nobody notices; on a long list it is catastrophic — a drill-through of 114 charge
   cards collapsed each card to a ~40px sliver with its text overflowing, which read as a wall of green bars.
   flex-shrink:0 makes them keep their natural height and lets overflow-y do its job. flex-GROW is untouched, so
   anything that legitimately fills the panel still does. */
.zt-wave-body > * { flex-shrink: 0; }
/* ⛔ A PLUMBING FORM MUST NOT EAT A GAP. THIS IS THE "why is the bottom padding bigger than the top" BUG, and it
   is invisible in every sense: the element has NO rendered content and NO height, yet it costs a full
   --zt-wave-gap.

   HOW: a wave body is a flex COLUMN with `gap`. A form whose only children are <input type="hidden"> has zero
   height (the UA stylesheet gives hidden inputs `display: none !important`) — but it is still a flex ITEM, and
   flex `gap` is charged between ITEMS, not between painted pixels. So the login panel got 13.6px of nothing
   between the last visible line and the footer, on top of the body's own 18.4px inset: 32px at the bottom
   against 18.4px at the top.

   ⚠️ IT ONLY BIT THE PLATFORM, WHICH IS WHY IT SURVIVED SO LONG. The form in question is the passkey sign-in
   form, and passkeys are suppressed on connected sites (the relying-party id is scoped to the platform domain).
   So the tenant login looked correctly balanced and the Zeal one did not — same control, same CSS, one extra
   invisible flex item.

   display:none removes it from flex layout entirely. It does NOT stop the form working: hidden inputs post
   regardless, and requestSubmit()/submit() operate fine on a non-displayed form — which is all JavaScript ever
   does with this one. */
.zt-plumbing-form { display: none; }

.zt-wave-body .zt-input-container,
.zt-wave-body .zt-input-checkbox,
.zt-wave-body .zt-billing-note,
.zt-wave-look .zt-input-container,
.zt-wave-look .zt-input-checkbox,
.zt-wave-look .zt-billing-note { margin: 0; }
/* ⚠️ THE GAP HAS TO BE RE-ESTABLISHED INSIDE A SECTION. The rule above zeroes every control's own margin because
   .zt-wave-body supplies the rhythm with its flex gap — but that gap only reaches its DIRECT children. Controls
   nested inside a ZtPageContent are not direct children, so they lost their margin and gained nothing: two
   stacked textareas sat flush against each other with literally no space. Re-declaring the SAME variable here
   means a section's controls breathe exactly like the top-level ones, from one source of truth. */
.zt-wave-body .zt-page-content-body,
.zt-wave-look .zt-page-content-body { display: flex; flex-direction: column; gap: var(--zt-wave-gap); }
/* PROSE INSIDE A WAVE EDITOR. The rule above makes the page-content body a flex column, which turns every INLINE
   child into its own flex item — a sentence containing <b> renders one fragment per line. Wrap prose in
   <p class="zt-wave-prose"> so it is a single flex item and the text flows normally. */
.zt-wave-prose { margin: 0; line-height: 1.5; }
/* ⭐ A CAPTION THAT OWNS THE BLOCK DIRECTLY BELOW IT — "Registered in this session:" over the names, and any
   other one-line title introducing a list inside a wave section (Vince, 2026-08-21: "there's a little bit too
   much vertical space between registered in this session and Miriam… that's just a title with items
   underneath it").
   WHY IT NEEDS A CLASS AT ALL: inside a wave body every child is a flex item separated by --zt-wave-gap, and a
   .zt-wave-intro adds its OWN paragraph margin on top of that — so a label sat nearly two gaps away from the
   list it introduces and read as an unrelated block. This zeroes the paragraph margin and cancels most of the
   flex gap that follows, leaving one tight relationship instead of two loose ones. Not a full cancel: at -1
   the caption and the first row touch. */
.zt-wave-label { margin: 0 0 calc(var(--zt-wave-gap) * -0.6); line-height: 1.5; }
/* A DESCRIPTION BELONGS TO THE FIELD UNDER IT. The wave body's standard gap leaves them looking like two
   unrelated blocks, so a page-content that is ONLY prose sits closer to whatever follows it. */
.zt-wave-body .zt-page-content:has(> .zt-page-content-body > .zt-wave-prose:only-child) { margin-bottom: calc(var(--zt-wave-gap) * -0.55); }
/* A STANDALONE NOTE AT THE TOP OF A WAVE — smaller than body prose, with EQUAL air above and below.
   ⚠️ IT IS NOT .zt-wave-prose ON PURPOSE. That class trips the rule directly above, which pulls a prose-only
   block DOWN onto the field it describes — right for a field description, wrong for a standalone warning.
   THE SPACING: above the note is the wave body's own padding-top (1.15rem); below it is the body's gap
   (--zt-wave-gap, 0.85rem). Those are different numbers, so the note pays the difference itself and the two
   gaps read as one. */
.zt-wave-note {
	margin: 0 0 calc(1.15rem - var(--zt-wave-gap));
	line-height: 1.45;
	font-size: 0.85rem;
}

/* The bare document input brings its own field margin outside a wave editor; in here the gap governs. */
.zt-wave-body .zt-docinput-bare { margin: 0; }

/* ---- WAVE EDITOR SECTIONS ---------------------------------------------- */
/* A long form of similar-looking blocks (a heading, a sentence, a control) reads as one undifferentiated column.
   Giving each section its own faint card — inset, hairline border, banded heading — makes the boundaries obvious
   without adding chrome that competes with the controls inside it. */
/* ⚠️ THE GAP IS THE ONLY SPACE BETWEEN SECTIONS — AND THE STRAY MARGIN WAS THE FIX, NOT ANY GAP NUMBER. Vince
   asked twice to bring the sections closer ("by, like, three pixels", then again 2026-08-19), and the first
   attempt subtracted 3px from the gap and changed almost nothing on screen. The gap was never the main spend:
   .zt-page-content carries its own margin-bottom (0.9em ≈ 14px) for ordinary pages, and it was STACKING on the
   flex gap — the same double-spacing trap .zt-section-card and .ztdc each had to zero in here. With the margin
   dead the one visible knob is --zt-wave-gap, which the body already applies. UNIVERSAL inside waves on
   purpose: any page-content in a wave sits on the flex gap, so its page margin is never anything but a trap. */
.zt-wave-body .zt-page-content,
.zt-wave-look .zt-page-content { margin-bottom: 0; }

/* ⛔ .zt-wave-section IS DEAD. It was the old flat card with a RULE under the heading; every user was migrated
   to .zt-wave-block on 2026-08-19 (ZtNewMemberControl, ZtAppealControl, ZtMemberExportControl) and its rules
   were DELETED, not commented. If the class turns up in markup it styles NOTHING — rename it to zt-wave-block. */

/* ══ 🎨 THE WAVE-EDITOR SECTION — THE ONE LOOK, AND IT IS LAW (Vince, 2026-08-19) ═══════════════════════════
   Approved on the new-member form and then made global the same day: *"This is absolute sexiness… we need to
   establish that this is what sections inside of our wave editors look like globally. Going forward for
   everyone needs to be a rule."* Built from his comparison of that form with the hosted-event editor: the
   new-member form's size, spacing and type; the event editor's banded head; no horizontal rule; dark grey
   text; blue icon; the faded near-white blues.

   ⚖️ THE RULE IS ENFORCED BY SELECTOR, NOT BY MEMORY. Three shapes get the look, and two of them are AUTOMATIC:
     · .zt-wave-block                        — the explicit class (also the only way for a TITLELESS section)
     · a titled ZtPageContent in a wave body — matched by :has(> .zt-page-title), no class needed
     · a .zt-section-card in a wave body     — the ZtActionCard shell (the hosted-event / sales-package / page
                                               editors), restyled below WITHOUT touching their markup or tones
                                               outside waves
   A new wave editor built from either standard section shell is therefore on-standard the day it is written —
   there is no class to forget. OUTSIDE wave bodies nothing here applies: page sections keep the tone system —
   UNLESS a page opts in wholesale with the .zt-wave-look wrapper (below).

   🎨 .zt-wave-look — THE SAME LOOK, OPTED INTO ON A NORMAL PAGE (Vince, 2026-08-19). The new-connected-account
   SETUP page (/connected/setup) hosts the REAL agreement / program / schedule editors and must wear these
   sections too ("this add agreement form doesn't have our new section… we need reusable code everywhere") —
   but it cannot BE a wave editor, because the schedule's slot editor opens its own ZtWaveEditor and a wave
   inside a wave hides the outer one (body.zt-wave-open). So the wrapper class joins .zt-wave-body in the
   selectors below: ONE look, defined ONCE, reachable from both. It also supplies the wave body's column
   rhythm, since there is no wave body to inherit it from. */
.zt-wave-look { display: flex; flex-direction: column; gap: 0.85rem; --zt-wave-gap: 0.85rem; }
/* AND THE WAVE EDITOR'S WIDTH (Vince, 2026-08-20: "the paragraph stretches all the way across the editor…
   follow the same rules of width that we do for other sections"). A wave PANEL is min(760px, 96vw); a page
   wearing the wave LOOK gets the same measure, centred — max-width instead of width because this column sits
   inside the shell page's own padding, so 100% already respects it on small screens. */
.zt-wave-look { width: 100%; max-width: 760px; margin-inline: auto; box-sizing: border-box; }

/* 📐 .zt-setup-walk — the /connected/setup walk's column: WIDTH ONLY, deliberately NOT .zt-wave-look
   (Vince, 2026-08-20: the wave clothes on a normal page flattened every card into "just the blue body…
   no section header"). The walk's cards are ordinary §3.9 banded sections; this wrapper only pins the
   column to the wave editor's measure so paragraphs never stretch across a wide screen. */
.zt-setup-walk { width: 100%; max-width: 760px; margin-inline: auto; box-sizing: border-box; }
/* EQUAL AIR ABOVE AND BELOW a walk card's content (Vince, 2026-08-20: "Configuration complete doesn't have as
   much padding above it as the paragraph below has below it… vertically centered"). The card body's own
   symmetric padding IS the spacing; the inner wrapper's 0.9em bottom margin and the paragraph's default
   margins were stacking onto the bottom only. */
.zt-setup-walk .zt-action-card-body > .zt-page-content { margin-bottom: 0; }
.zt-setup-walk .zt-action-card-body .zt-page-content p { margin: 0; }
.zt-setup-walk .zt-action-card-body > .zt-alert:last-child,
.zt-setup-walk .zt-action-card-body .zt-page-content p:last-child { margin-bottom: 0; }
/*

   SO THE SECTION IS A LITTLE CARD WITH A BANDED HEAD, and it is assembled from the pieces each screen was
   already contributing:
     · THE BAND replaces the horizontal rule. A rule under a heading and a tinted head do the same job — say
       where the section starts — and doing both was the thing that read as fussy. The band is the one that
       survives, because it also closes the section off at the top the way the wave's own header does.
     · DARK GREY TEXT, BLUE ICON. The title now inherits .zt-page-title's own --zeal-dark-grey (the blue
       override is GONE); only the icon carries the tone. Blue-on-blue was the other half of the fussiness.
     · THE NEW-MEMBER FORM'S SIZING SURVIVES UNTOUCHED — .zt-page-title's 1.05rem/700 and this block's padding
       are the numbers he approved; nothing here changes them.

   ⛔ PADDING MOVES OFF THE BOX AND ONTO THE TWO HALVES. A band cannot reach the section's edges while the box
   itself is inset. Anything added here must pad the HEAD and the BODY, never .zt-wave-block itself.

   ⛔ AND THE BAND ROUNDS ITSELF — NO `overflow: hidden` ON THE SECTION. Clipping is the obvious way to keep a
   band inside a radius and it would be a bug here: these sections contain DROPDOWNS, and an overflow-clipped
   ancestor cuts off any panel that opens past the section's edge. The band carries its own 7px top corners
   instead (8px radius less the 1px border), which needs no clipping at all. The BOTTOM corners need nothing —
   the body is transparent and the section's own background is what rounds. */
.zt-wave-block,
.zt-wave-body .zt-page-content:has(> .zt-page-title),
.zt-wave-look .zt-page-content:has(> .zt-page-title) {
	padding: 0;
	border: 1px solid var(--zt-neutral-bd);
	border-radius: 8px;
	background: var(--zeal-blue-wash);               /* the body: not white, barely blue */
}
.zt-wave-block > .zt-page-title,
.zt-wave-body .zt-page-content > .zt-page-title,
.zt-wave-look .zt-page-content > .zt-page-title {
	margin: 0;
	padding: 0.62rem 1rem;
	background: var(--zeal-blue-band);               /* the head: the faded light neutral blue */
	border-bottom: 1px solid var(--zt-neutral-bd);   /* seats the band; not the old rule under floating text */
	border-radius: 7px 7px 0 0;                      /* see the note above — NOT overflow:hidden */
	color: var(--zeal-dark-grey);
}
.zt-wave-block > .zt-page-title .zt-page-title-icon,
.zt-wave-body .zt-page-content > .zt-page-title .zt-page-title-icon,
.zt-wave-look .zt-page-content > .zt-page-title .zt-page-title-icon { color: var(--zeal-blue); }
/* ⚠️ THE BOTTOM IS BIGGER THAN THE TOP ON PURPOSE, AND THAT IS WHAT MAKES THEM LOOK THE SAME (Vince,
   2026-08-19: "whatever the last control is in the section should have enough padding underneath it — it should
   match whatever is at the top"). It already exceeded the top numerically (0.95 vs 0.85) and still READ tighter:
   the section usually ends on a hard-edged input box and starts with soft text, and an edge needs a few more
   pixels than a line of type to feel equally far from the border. Optical spacing, not geometric — do NOT
   "correct" these to symmetric. */
.zt-wave-block > .zt-page-content-body,
.zt-wave-body .zt-page-content:has(> .zt-page-title) > .zt-page-content-body,
.zt-wave-look .zt-page-content:has(> .zt-page-title) > .zt-page-content-body { padding: 0.85rem 1rem; }
/* A SECTION WITH NO TITLE still needs its inset — the body is the whole card there. */
.zt-wave-block > .zt-page-content-body:first-child { padding-top: 0.9rem; }

/* ── THE SAME LOOK ON THE ZtActionCard SHELL (.zt-section-card) INSIDE A WAVE ───────────────────────────────
   The hosted-event, sales-package and page editors build their sections as CLAUDE.md §3.9 action cards, and
   rewriting eleven of them to ZtPageContent would have traded markup churn for nothing — the shell is fine,
   only its wave-time CLOTHES change. These rules restyle the card to be pixel-for-pixel this section: band,
   wash, paddings, title size, blue icon. OUTSIDE a wave body none of this matches and §3.9's tone system is
   untouched.

   ⚠️ TONES ARE DELIBERATELY OVERRIDDEN IN HERE — a zt-happy card in a wave comes out neutral blue like
   everything else. That is the instruction ("sections of a form inside a wave editor all look the same"), and
   it is why the header/footer selectors carry .zt-action-card-flush: the tone rules are (0,3,0) and sit later
   in this file, so the extra class is what makes these win. Drop it and the greens come back silently.

   ⚠️ .zt-action-card-flush KEEPS ITS overflow:hidden (its own base rule) — the no-clipping note above is about
   .zt-wave-block. If a dropdown ever lands inside one of THESE cards in a wave, that clip is the first suspect. */
.zt-wave-body .zt-section-card.zt-action-card-flush,
.zt-wave-look .zt-section-card.zt-action-card-flush {
	border: 1px solid var(--zt-neutral-bd);
	border-radius: 8px;
	background: var(--zeal-blue-wash);
}
.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-header,
.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-footer,
.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-header,
.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-footer {
	background: var(--zeal-blue-band);
	border-color: var(--zt-neutral-bd);
}
.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-header,
.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-header { padding: 0.62rem 1rem; }
.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-body,
.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-body { padding: 0.85rem 1rem; background: transparent; }
/* THE TITLE MATCHES .zt-page-title — 1.05rem, dark grey, blue icon. (.zt-section-title's own base is 1.15rem
   with a tiffany icon; both lose to these three-class selectors at any source order.) */
.zt-wave-body .zt-section-card .zt-section-title,
.zt-wave-look .zt-section-card .zt-section-title { font-size: 1.05rem; color: var(--zeal-dark-grey); }
.zt-wave-body .zt-section-card .zt-section-title .zt-fa,
.zt-wave-look .zt-section-card .zt-section-title .zt-fa { color: var(--zeal-blue); }

@media (max-width: 599px) {
	/* Tighter inset on a phone — the border and the band still do the separating, the padding just costs width.
	   The bottom keeps its optical extra (see above); only the sides and top shrink. Both shells together, so
	   they cannot drift on the one axis phones actually feel. */
	.zt-wave-block > .zt-page-title,
	.zt-wave-body .zt-page-content > .zt-page-title,
	.zt-wave-look .zt-page-content > .zt-page-title,
	.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-header,
	.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-header { padding: 0.55rem 0.7rem; }
	.zt-wave-block > .zt-page-content-body,
	.zt-wave-body .zt-page-content:has(> .zt-page-title) > .zt-page-content-body,
	.zt-wave-look .zt-page-content:has(> .zt-page-title) > .zt-page-content-body,
	.zt-wave-body .zt-section-card.zt-action-card-flush .zt-action-card-body,
	.zt-wave-look .zt-section-card.zt-action-card-flush .zt-action-card-body { padding: 0.7rem; }
}
/* zero the title/note margins AND tighten the title's line box so its gap to the note MATCHES the note→control gap. */
.zt-wave-body .zt-charge-title { margin: 0; line-height: 1.15; }
.zt-wave-body .zt-pay-note { margin: 0; }
/* FOOTER — action row, ALWAYS left-aligned (house rule), generous padding above/below the buttons. */
.zt-wave-footer { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: 0.6rem; justify-content: flex-start; padding: var(--zt-wave-pad-y) var(--zt-wave-pad-x); border-top: 1px solid #ececec; background: #fafafa; }
/* ---- THE TONE RULE: FOOTER ALWAYS MATCHES HEADER ----------------------- */
/* A panel is ONE object, so it gets ONE tone. A green header over a grey footer read as two unrelated bars with
   the form floating between them; matching them frames the content instead. GLOBAL and automatic — every wave
   editor, present and future, gets this from its Happy/Warn/Neutral flag with nothing extra to remember. */
.zt-wave-happy .zt-wave-footer { background: var(--zt-happy-bg); border-top-color: var(--zt-happy-bd); }
.zt-wave-warn .zt-wave-footer { background: var(--zt-warn-bg); border-top-color: var(--zt-warn-bd); }
.zt-wave-neutral .zt-wave-footer { background: var(--zt-neutral-bg); border-top-color: var(--zt-neutral-bd); }
.zt-wave-error .zt-wave-footer { background: var(--zt-error-bg); border-top-color: var(--zt-error-bd); }

/* SMALL SCREEN — the footer buttons are MENU TILES now (see the .ztbtn-menu block), and a tile is already the
   compact shape this rule used to reach for by shrinking full-size buttons. So the phone case only tightens the
   BAR — its gap and padding — and leaves the tiles alone. They are flex:0 0 auto and content-sized, so a
   Cancel/Reset/Save trio fits one row with room to spare and wraps cleanly if a fourth (Populate) joins it.
   Same COMPACT (599px) breakpoint the rest of the wave editor uses; still LEFT-aligned (house rule). */
@media (max-width: 599px) {
	.zt-wave-footer { gap: 0.4rem; padding: 0.8rem 0.9rem; }
}

/* WAVE-EDITOR standard header — the picture + title + N-line identity (a wave-editor PROPERTY, so every wave editor
   reads the same): readable line gap + font, with the media-object's multi-line negative margin cleared. */
.zt-wave-head.ztmo { gap: 0.7rem; }                 /* avatar ↔ text horizontal gap (approved) */
.zt-wave-head .ztmo-content { gap: 0.2rem; }        /* tight-but-readable member / connected account / program stack */
.zt-wave-head .ztmo-subtitle { margin-top: 0; font-size: 0.92rem; }
/* CLOSE (X) — match the Cancel button's grey tone, a touch lighter, no shadow so it reads the same as Cancel. */
.zt-wave-close.ztbtn.ztbtn-icon { background: #b0b0b0; border-color: #b0b0b0; box-shadow: none; }
.zt-wave-close.ztbtn.ztbtn-icon:hover { background: var(--zeal-silver); border-color: var(--zeal-silver); }
.zt-pay-note { font-size: 0.9rem; line-height: 1.35; color: var(--zeal-grey); }
.zt-pay-note b { color: var(--zeal-dark-grey); }

/* ---- ADDRESS GROUP ----------------------------------------------------- */
/* NO margin override — address sub-fields use the SAME vertical rhythm as every other
   control (.zt-input-container 0.75em/0.5em) so spacing is consistent system-wide. */
.zt-address { max-width: var(--zeal-field-max); }

/* ---- MULTI-SELECT (dual list) ----------------------------------------- */
/* TWO STACKED, INDEPENDENTLY-SCROLLING LISTS: AVAILABLE (TOP, NEUTRAL) + SELECTED (BOTTOM, GREEN). */
/* FULL WIDTH LIKE THE RICH-TEXT EDITOR, AND NO BIG LEFT FIELD-ICON NEXT TO THE BOXES. */
.zt-input-container:has(.zt-multiselect) { max-width: 100%; }
.zt-input-container:has(.zt-multiselect) .zt-input-field-icon { display: none; }
/* …EXCEPT INSIDE A WAVE EDITOR, where the multi-select sits in the form column and must match the other
   fields (Vince, 2026-08-20: "the width of that multi select tool here to be the width of the drop down and
   text inputs below"). Same shared measure every input uses. */
.zt-wave-body .zt-input-container:has(.zt-multiselect),
.zt-wave-look .zt-input-container:has(.zt-multiselect) { max-width: var(--zeal-field-max); }
.zt-multiselect {
	display: flex;
	flex-direction: column;
	gap: 0.75em;
	max-width: 100%;
}

.zt-ms-list {
	border: 1px solid var(--zeal-silver);
	border-radius: 6px;
	overflow: hidden;
	background: var(--zeal-white);
}

.zt-ms-head {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.5em;
	padding: 0.4em 0.75em;
	font-size: 0.8rem;
	font-weight: 600;
	letter-spacing: 0.02em;
	text-transform: uppercase;
	color: #555;
	background: #f1f3f4;
	border-bottom: 1px solid var(--zeal-silver);
}

/* COUNT BADGE — A BUTTON: CLICK TO POP THE COMPACT LIST MODAL. */
.zt-ms-count {
	min-width: 1.6em;
	padding: 0.1em 0.55em;
	border: none;
	border-radius: 999px;
	text-align: center;
	font-family: inherit;
	font-size: 0.72rem;
	line-height: 1.4;
	color: var(--zeal-white);
	background: var(--zeal-silver);
	cursor: pointer;
	transition: filter 0.12s ease, transform 0.12s ease;
}
.zt-ms-count:hover { filter: brightness(1.12); }
.zt-ms-count:active { transform: scale(0.94); }

/* PER-LIST FILTER BOX */
.zt-ms-filter {
	display: flex;
	align-items: center;
	gap: 0.5em;
	padding: 0.4em 0.75em;
	background: #fafbfb;
	border-bottom: 1px solid #eee;
}
.zt-ms-filter .zt-fa { color: #9a9a9a; font-size: 0.85rem; }
.zt-ms-filter input {
	flex: 1 1 auto;
	min-width: 0;
	border: none;
	background: transparent;
	outline: none;
	font-size: 0.9rem;
	color: var(--zeal-dark-grey);
}
.zt-ms-filter input::placeholder { color: #b3b3b3; }
.zt-ms-filter-clear {
	display: inline-flex;
	border: none;
	background: transparent;
	padding: 0;
	color: #b3b3b3;
	cursor: pointer;
}
.zt-ms-filter-clear:hover { color: var(--zeal-red); }
.zt-ms-selected .zt-ms-filter { background: var(--zt-happy-bg); border-bottom-color: var(--zt-happy-bd); }

.zt-ms-scroll { overflow-y: auto; }

.zt-ms-item {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 0.75em;
	padding: 0.3em 0.4em 0.3em 0.75em;
	border-bottom: 1px solid #eee;
}
.zt-ms-item:last-child { border-bottom: none; }

.zt-ms-text {
	flex: 1 1 auto;
	min-width: 0;
	overflow: hidden;
	text-overflow: ellipsis;
	white-space: nowrap;
	font-size: 0.95rem;
	color: var(--zeal-dark-grey);
}

.zt-ms-empty {
	padding: 1em 0.75em;
	text-align: center;
	font-size: 0.85rem;
	font-style: italic;
	color: #999;
}

/* SELECTED LIST = GREEN THEME */
.zt-ms-selected .zt-ms-head {
	color: var(--zeal-green);
	background: var(--zeal-light-green);
	border-bottom-color: #cfe6cf;
}
.zt-ms-selected .zt-ms-count { background: var(--zeal-green); }
.zt-ms-item-selected { background: var(--zeal-light-green); border-bottom-color: #dcebdc; }
.zt-ms-item-selected .zt-ms-text { color: #214521; }

/* REQUIRED + nothing selected: the platform's orange "needs attention" cue lives on the field-icon,
   which this control hides — so surface it on the (empty) selected list instead. */
.zt-input.required:has(.zt-multiselect) .zt-ms-selected { border-color: var(--zeal-orange); }
.zt-input.required:has(.zt-multiselect) .zt-ms-selected .zt-ms-head {
	color: var(--zeal-orange);
	background: #fdf3e6;
	border-bottom-color: #f3d9b5;
}
.zt-input.required:has(.zt-multiselect) .zt-ms-selected .zt-ms-count { background: var(--zeal-orange); }

/* COMPACT LIST POPUP — tight, small-text, scrollable read-only list (count-badge click). */
.zt-ms-modal-list { max-height: 55vh; overflow-y: auto; margin: -0.25em 0; }
.zt-ms-modal-item {
	padding: 0.18em 0.4em;
	font-size: 0.82rem;
	line-height: 1.5;
	color: var(--zeal-dark-grey);
	border-bottom: 1px solid #f1f1f1;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}
.zt-ms-modal-item:last-child { border-bottom: none; }
.zt-ms-modal-item:nth-child(even) { background: #fafbfb; }

/* ---- PLACEHOLDER (shimmer skeleton) ----------------------------------- */
.zt-ph { width: 100%; }
.zt-ph-header { display: flex; gap: 0.75em; margin-bottom: 0.85em; }
.zt-ph-avatar { flex: 0 0 auto; width: 48px; height: 48px; border-radius: 6px; }
.zt-ph-header-lines { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; gap: 0.55em; }
.zt-ph-card { width: 100%; height: 64px; border-radius: 6px; margin-bottom: 0.85em; }
.zt-ph-lines { display: flex; flex-direction: column; gap: 0.6em; }
.zt-ph-line { height: 0.7em; border-radius: 4px; width: 100%; }
/* Deterministic shimmer-line widths (utility classes — no inline widths). */
.zt-ph-w-96 { width: 96%; } .zt-ph-w-90 { width: 90%; } .zt-ph-w-88 { width: 88%; }
.zt-ph-w-84 { width: 84%; } .zt-ph-w-82 { width: 82%; } .zt-ph-w-72 { width: 72%; }
.zt-ph-w-70 { width: 70%; } .zt-ph-w-64 { width: 64%; } .zt-ph-w-58 { width: 58%; }
.zt-ph-w-45 { width: 45%; }

/* THE SHIMMER: a pale band sweeping left→right across a light-grey base. */
.zt-ph-shimmer {
	background: #eceff1;
	background-image: linear-gradient(to right, #eceff1 8%, #e0e4e7 18%, #eceff1 33%);
	background-size: 1200px 100%;
	background-repeat: no-repeat;
	animation: zt-ph-shimmer 1.6s linear infinite;
}
@keyframes zt-ph-shimmer {
	0% { background-position: -600px 0; }
	100% { background-position: 600px 0; }
}

/* ---- PAGE SECTION ----------------------------------------------------- */
.zt-page-section {
	position: relative;
	box-sizing: border-box;
	width: 100%;
	background: var(--zeal-white);
	border-radius: 6px;
	/* VERTICAL SPACING HALVED (Vince, 2026-08-04). The gap a reader sees between two stacked sections is three
	   values, not one: the upper section's BOTTOM padding + the margin between them + the lower section's TOP
	   padding. It was 1.5 + 0.75 + 1.25 = 3.5em; every one of them is now exactly half, so the gap is 1.75em —
	   50%, measured on what shows rather than on any single property.
	   HORIZONTAL PADDING IS DELIBERATELY UNTOUCHED at 1.5em: it sets the vertical line the section icons sit on,
	   and .zeal-card-logo is aligned to that same 1.5em. Changing it would pull the logo out of line. */
	padding: 0.625em 1.5em 0.75em;
	margin-bottom: 0.375em;
	color: #555;
}
/* the body's last child shouldn't butt against the card edge */
.zt-page-section-body > :last-child { margin-bottom: 0; }
/* ── SMALLER SCREENS: SECTIONS FLOAT, THEY DO NOT FULL-BLEED (Vince, 2026-08-07) ────────────────────────────
   "Let's put, like, a four pixel border radius so that these look like they're floating… this needs to be a
   global change, I don't wanna have to adjust this page to page to page."

   ⚠️ THE SIDE GUTTER IS NOT HERE ANY MORE, AND THAT IS THE POINT. A first attempt put `margin: 0 1%` on
   `.zt-page-section` + `.zt-action-card.zt-section-card`, which meant every section had to be WEARING one of
   those two classes to float — and most are not (`zt-programs-section`, `zt-cc-section`, `zt-member-search-card`,
   `zt-user-results`, the trial/prospect cards …). So a handful floated and the rest stayed edge to edge, which
   is precisely the "program memberships isn't the same width as accounts" report. The gutter now lives ONCE on
   the page container as `--zt-page-gutter` (see its remarks in :root), so a section does not have to opt in,
   know its own name, or even exist yet.

   ALL THAT IS LEFT HERE IS THE RADIUS, which genuinely is per-card. It replaces an old rule that squared every
   card to 0 for a full-bleed layout that no longer exists. EVERY card gets it, nested or not — a square card
   inside a rounded one looks like a rendering fault.

   NOTE the profile-header radius + full-bleed opt-out are handled LOWER in the file — they must come AFTER those
   elements' base rules or source-order lets the base win. */
@media (max-width: 599px) {
	/* Doubled class raises specificity above the base .zt-action-card{border-radius:6px} (defined LATER in the
	   file), which is why the old square rule was written this way too. */
	.zt-page-section,
	.zt-action-card.zt-action-card { border-radius: 4px; }
}

/* ---- profile header (ZtProfileHeader) — social-style cover + overlapping avatar + identity ---- */
.zt-profile-header {
	/* TWO knobs: --zt-avatar (pic size; overlap is 50% of it) and --zt-cover-h (cover banner height, tuned
	   independently — a short wide banner, cropped from the 16:9 default via object-fit). */
	--zt-avatar: 165px;
	--zt-cover-h: 200px;
	position: relative; box-sizing: border-box; width: 100%;
	background: var(--zeal-white); border-radius: 6px; margin-bottom: 0.75em;
	overflow: hidden; color: #555;
}
.zt-profile-cover {
	position: relative; width: 100%; height: var(--zt-cover-h);
	/* GRADIENT FALLBACK — shows through if the cover image is missing/broken (echoes the default art). */
	background: linear-gradient(120deg, #14141e 0%, #1c1c34 55%, #0d0d12 100%);
}
.zt-profile-cover-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.zt-profile-cover.img-failed .zt-profile-cover-img { display: none; }

.zt-profile-body { display: flex; gap: 1rem; padding: 0 1.5em 1.25em; align-items: flex-start; }
/* AVATAR — pulled up so it overlaps the cover by half; z-index lifts it ABOVE the (positioned) cover so the
   top half sits in FRONT of the cover, not behind it. The ring blends into the card. */
.zt-profile-avatar {
	position: relative; z-index: 2;
	flex: 0 0 auto; width: var(--zt-avatar); height: var(--zt-avatar);
	margin-top: calc(var(--zt-avatar) / -2); margin-left: 0.25rem;
	border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center;
	background: #444; color: #bbb; font-size: calc(var(--zt-avatar) * 0.42);
	border: 4px solid var(--zeal-white);
}
.zt-profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
.zt-profile-avatar-fallback { display: inline-flex; align-items: center; justify-content: center; }
.zt-profile-avatar:has(img):not(.img-failed) .zt-profile-avatar-fallback { display: none; }
.zt-profile-avatar.img-failed img { display: none; }

/* The identity reserves exactly the avatar's lower half and BOTTOM-aligns its text, so the bottom of the
   birthdate line sits on the bottom of the profile picture, with the name up near the avatar's middle. */
.zt-profile-identity {
	/* CENTER the name/contact block vertically in the white area: give it a TOP padding equal to the body's BOTTOM
	   padding (1.25em), so the gap above the name always equals the gap below the birthday — independent of how many
	   contact lines there are (the avatar's negative margin shrinks the flex line, so pure justify-content can't). */
	min-width: 0; display: flex; flex-direction: column; justify-content: center;
	min-height: calc(var(--zt-avatar) / 2 - 12px); padding-top: 1.25em;
}
.zt-profile-name { margin: 0; font-size: 1.3rem; font-weight: 700; color: var(--zeal-dark-grey); line-height: 1.15; }
.zt-profile-meta { display: flex; flex-direction: column; gap: 0.14rem; margin-top: 0; font-size: 0.9rem; line-height: 1.2; }
.zt-profile-meta-item { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.zt-profile-meta-item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-profile-meta-icon { color: var(--zeal-tiffany); flex: 0 0 auto; }

/* EDIT AFFORDANCE — hover-revealed camera buttons (rendered only when .editable, i.e. CanEditPhotos). */
.zt-profile-edit {
	position: absolute; z-index: 3; border: 0; cursor: pointer; padding: 0;
	display: inline-flex; align-items: center; justify-content: center;
	width: 2.25rem; height: 2.25rem; border-radius: 50%;
	background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 0.95rem;
	opacity: 0; transition: opacity 0.15s ease, background 0.15s ease;
}
.zt-profile-edit:hover, .zt-profile-edit:focus-visible { background: rgba(0, 0, 0, 0.78); }
.zt-profile-edit-cover { bottom: 0.6rem; right: 0.6rem; }
.zt-profile-edit-avatar { bottom: 12%; right: 12%; }
/* reveal on hover of the respective image, and dim the image so it reads as editable */
.editable .zt-profile-cover:hover .zt-profile-edit-cover,
.editable .zt-profile-avatar:hover .zt-profile-edit-avatar { opacity: 1; }
.editable .zt-profile-cover:hover .zt-profile-cover-img { filter: brightness(0.82); }
.editable .zt-profile-avatar:hover img { filter: brightness(0.7); }
/* touch devices (no hover) — keep the buttons visible so they're reachable */
@media (hover: none) { .editable .zt-profile-edit { opacity: 0.9; } }

/* EDIT THE SUBJECT — the far-right action box (ZtProfileHeader CanEdit). A SIBLING of the identity, pushed
   right by margin-left:auto.

   ⚠️ IT COPIES THE IDENTITY'S OWN VERTICAL-CENTERING TRICK RATHER THAN USING align-self:center, and it has to.
   The flex LINE is taller than the white area — the avatar's negative top margin pulls it up over the cover —
   so align-self:center would centre against that overhang and float the button up into the banner. Matching the
   identity's min-height + padding-top makes both boxes describe the SAME box, so the button sits level with the
   name/contact block however many contact lines there are. */
.zt-profile-actions {
	flex: 0 0 auto; margin-left: auto;
	/* STRETCH TO THE FLEX LINE, THEN CENTRE INSIDE IT. The first attempt copied the identity's min-height and
	   padding, which only centres the button when the identity is EXACTLY that tall — with three contact lines
	   the identity grows, this box does not, and the button sits high (Vince, 2026-08-21). align-self:stretch
	   overrides the body's align-items:flex-start so this box always spans the same height the identity does,
	   and align-items:center then lands the button on the identity's own centre line whatever it contains.
	   The padding-top matches the identity's, so both boxes start below the avatar overhang. */
	align-self: stretch;
	display: flex; align-items: center; justify-content: flex-end;
	padding-top: 1.25em;
}

/* MOBILE — stack + center the avatar and identity on narrow screens. */
@media (max-width: 599px) {
	.zt-profile-body { flex-direction: column; align-items: center; text-align: center; gap: 0.5rem; }
	.zt-profile-avatar { margin-left: 0; }
	.zt-profile-identity { min-height: 0; justify-content: flex-start; align-items: center; padding-top: 0; }
	.zt-profile-meta { align-items: center; }
	/* THE BUTTON DROPS TO THE BOTTOM — the column direction retires margin-left:auto, so it simply lands last,
	   under the contact lines, which is the house rule for card actions on a phone. Reset the centering box. */
	.zt-profile-actions { align-self: auto; padding-top: 0.25rem; margin-left: 0; justify-content: center; }
}

/* ---- media object (ZtMediaObject) — circular avatar on the left (vertically centered) + a stacked content
   column on the right. Avatar diameter is driven by --zt-media-avatar. Fallback icon reveals when no working
   <img> is present (same :has technique as the profile-header avatar). ---- */
.ztmo { display: flex; align-items: center; gap: 1rem; }
.ztmo-media {
	position: relative; flex: 0 0 auto;
	width: var(--zt-media-avatar, 72px); height: var(--zt-media-avatar, 72px);
	border-radius: 50%; overflow: hidden;
	display: inline-flex; align-items: center; justify-content: center;
	background: #444; color: #bbb; font-size: calc(var(--zt-media-avatar, 72px) * 0.42);
	border: 3px solid var(--zeal-white);
}
/* THE MEDIA COLUMN — inert (display:contents) unless the caller passes MediaActions, in which case the avatar
   gains a small icon strip directly beneath it and the pair behaves as one flex item. */
.ztmo-media-col { display: contents; }
.ztmo-media-col.has-actions { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 0 0 auto; }
.ztmo-media-actions { display: flex; align-items: center; gap: 6px; }
.ztmo-media-img { width: 100%; height: 100%; object-fit: cover; }
.ztmo-media-fallback { display: inline-flex; align-items: center; justify-content: center; }
.ztmo-media:has(.ztmo-media-img):not(.img-failed) .ztmo-media-fallback { display: none; }
.ztmo-media.img-failed .ztmo-media-img { display: none; }
/* ── SMALLER SCREENS: THE PROFILE HEADER IS THE ONE SECTION THAT FULL-BLEEDS ───────────────────────────────
   Vince, 2026-08-07: "the profile picture cover photo, that should be full width, it should take up the full
   space, there should be no border radius. That's a special one for BOTH the user and the connected account."

   It is a COVER PHOTO. Every other section is content on a white card and wants to look like it is floating
   over the Starfield; this one is an image that reads as the top of the screen, and a 12px moat of stars around
   a cover photo just looks like the photo failed to load. So it alone pulls itself back out over the page
   gutter — the documented opt-out on --zt-page-gutter — and keeps square corners.

   ONE RULE COVERS BOTH HEADERS: UserProfileHeader and ConnectedProfileHeader are both ZtProfileHeader.
   (Avatar drop-shadows are gone entirely — removed from the base rules above — so there's nothing to override
   here.) Placed AFTER the base rule so it wins on source order. */
@media (max-width: 599px) {
	.zt-profile-header {
		border-radius: 0;
		width: auto;
		margin-left: calc(var(--zt-page-gutter, 16px) * -1);
		margin-right: calc(var(--zt-page-gutter, 16px) * -1);
	}
	/* AND IT HUGS THE TOP EDGE TOO — but ONLY when it is the first thing on the page, which is the only place a
	   cover photo belongs. Every other page now gets a top gutter matching its sides; a hero image is the one
	   thing that should not, because a 12px band of stars above a photo that bleeds left and right reads as a
	   mistake rather than as spacing. `:first-child` keeps this honest: drop a profile header lower down some
	   future page and it spaces normally instead of biting into whatever sits above it. */
	.zt-profile-header:first-child { margin-top: calc(var(--zt-page-gutter, 16px) * -1); }
}
.ztmo-body { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; }
.ztmo-content { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.ztmo-actions { flex: 0 0 auto; margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
/* ACTIONS BELOW THE LINES (ZtMediaObject ActionsBelow) — the legacy trial-class / prospect card, where the
   buttons are the last child of the DETAILS column, left-aligned under the detail lines, not a column on the
   right. The .ztmo-content 0.5rem gap already supplies the legacy `mt-2` spacing, so no extra margin here.

   TWO CLASSES DELIBERATELY (.ztmo-actions.ztmo-actions-below): the mobile block further down re-points plain
   .ztmo-actions to order:2 so a right-hand action column tucks under the NAME. This variant is already in the
   content column and must stay LAST, so it needs to outrank that rule at any source order.

   order:4 for the same reason as .zt-member-program-actions: on mobile .ztmo-content becomes display:contents
   and its children restack by `order` (title 1 / actions 2 / subtitle 3). Without an explicit order this row
   would default to 0 and jump ABOVE the name. */
.ztmo-actions.ztmo-actions-below { margin-left: 0; justify-content: flex-start; order: 4; }
/* AN "ENTER" FORM MUST NOT AFFECT LAYOUT. These exist only to carry a hidden id in a POST body — but a form is
   a BLOCK, so inside a flex button row it collapses to ONE flex item and the buttons within it lose the row's
   gap and bunch up while their neighbours keep it. display:contents removes the form box entirely, so its
   buttons become direct flex children and line up with everything else.
   ⚠️ EVERY new enter-form needs this line. Forgetting it on .zt-program-enter is exactly how the Programs
   footer ended up with Edit and Schedule crushed together. */
.zt-edit-membership-enter { display: contents; }
.zt-program-enter { display: contents; }

/* ── 🔢 THE CONNECTED-ACCOUNT NAV IS TWO ROWS ───────────────────────────────────────────────────────────────
   Vince, 2026-08-18: "instead of having one row, I want two rows of five buttons. So home, dashboard, members,
   check ins, communications on one row, and then the second row is programs, docs, agreements, facilities,
   site." Used by BOTH cards that carry this bar — the user home's per-academy header (icon squares) and the
   admin Manage Connected Accounts result row (labelled tiles, plus Charge at the end of row two).

   ⚠️ .zt-ca-enter IS NO LONGER display:contents, AND THAT IS THE WHOLE CHANGE. The trick above works for ONE
   row and structurally forbids two: a form with no box of its own cannot group anything. So this one form keeps
   its box, becomes the action block, and stacks .zt-ca-nav-row lines inside it — the buttons now take their gap
   from the ROW rather than from the container, which is what the display:contents line was buying. (The hidden
   ca input is display:none, so it still costs no line.) The other two enter-forms are untouched: they really do
   carry a single row and still need to vanish.

   ROWS, NOT A 5- OR 6-COLUMN GRID, AND DELIBERATELY: every button here is permission-gated, and in a grid a
   viewer who cannot see one destination pulls a button up out of the next row — the two groups stop being
   groups, and which button moves depends on who is looking. A row that is short is simply short. */
.zt-ca-enter { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; min-width: 0; }
.zt-ca-nav-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 6px; }

/* ── ASSIGNMENT BOARD (two-column drag/tap) ─────────────────────────────────────────────────────────────── */
.zt-board { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
.zt-board.is-busy { opacity: 0.6; pointer-events: none; }
.zt-board-col {
	display: flex; flex-direction: column; background: var(--zeal-white);
	border: 1px solid #e6e8e8; border-radius: 12px; padding: 0.8rem 0.85rem 0.9rem;
}
.zt-board-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.6rem; }
.zt-board-title { display: inline-flex; align-items: center; gap: 0.45rem; font-size: 1.05rem; font-weight: 700; color: var(--zeal-dark-grey); }
.zt-board-col:first-child .zt-board-title { color: var(--zeal-green); }
.zt-board-count {
	min-width: 1.6rem; text-align: center; padding: 0.1rem 0.5rem; border-radius: 999px;
	background: #f0f2f2; color: var(--zeal-grey); font-weight: 700; font-size: 0.85rem;
}
.zt-board-search {
	display: flex; align-items: center; gap: 8px; padding: 0.45rem 0.7rem; margin-bottom: 0.6rem;
	background: #f4f5f5; border-radius: 9px; color: var(--zeal-grey);
}
.zt-board-search input { flex: 1 1 auto; border: none; outline: none; background: transparent; font-size: 0.98rem; color: var(--zeal-dark-grey); }
.zt-board-list { display: flex; flex-direction: column; gap: 7px; overflow-y: auto; max-height: 26rem; padding: 2px; overscroll-behavior: contain; }
.zt-board-item {
	display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box; text-align: left;
	padding: 0.55rem 0.7rem; border-radius: 10px; border: 2px solid transparent; background: #f6f7f7;
	cursor: grab; font-size: 0.98rem; color: var(--zeal-dark-grey); font-family: inherit;
}
.zt-board-item:active { cursor: grabbing; }
.zt-board-item:hover { border-color: var(--zeal-tiffany); background: #eefdfc; }
.zt-board-item.is-assigned { background: var(--zt-happy-bg); }
.zt-board-item-icon { flex: 0 0 auto; color: var(--zeal-tiffany); font-size: 1.05rem; }
.zt-board-item-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.zt-board-item-label { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-board-item-sub { font-size: 0.8rem; color: var(--zeal-grey); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-board-item-move { flex: 0 0 auto; font-size: 1.05rem; opacity: 0.65; }
.zt-board-item.is-assigned .zt-board-item-move { color: var(--zeal-red); }
.zt-board-item.is-available .zt-board-item-move { color: var(--zeal-green); }
.zt-board-item:hover .zt-board-item-move { opacity: 1; }
.zt-board-empty { text-align: center; color: var(--zeal-grey); padding: 1.2rem; font-size: 0.92rem; }
@media (max-width: 599px) { .zt-board { grid-template-columns: 1fr; } .zt-board-list { max-height: 20rem; } }
/* Roles admin page — tier tabs + role chips. */
.zt-roles-tabs, .zt-roles-roles { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.4rem; }

/* ── RANK HISTORY (edit-membership section) ─────────────────────────────────────────────────────────────── */
.zt-rank-loading { display: flex; align-items: center; gap: 0.5rem; color: var(--zeal-grey); padding: 1rem 0.2rem; }
.zt-rank-new { margin-bottom: 0.8rem; }
/* Rank History header (filled grey band) — title left, New button pinned top-right (12px inset, like the membership Close). */
/* position:relative makes THIS header the containing block for the absolutely-positioned New button, so its
   top:50% centers within the header band — NOT within the whole (very tall) action card. */
.zt-rankhistory .zt-action-card-header { display: flex; align-items: center; min-height: 2.9rem; position: relative; }
.zt-rank-header-title { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 1.15rem; font-weight: 700; color: #333333; }
.zt-rank-header-title .zt-fa { color: var(--zeal-tiffany); }
/* New button — VERTICALLY CENTERED in the header band (top:50% + translate), so the space above and below it is
   even regardless of the band/button height (a fixed top:12px left uneven gaps). The title is already centered by
   the header's align-items:center. */
.ztbtn.zt-rank-new-btn { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); z-index: 2; }
.zt-rank-form { background: #f6f7f7; border: 1px solid #e6e8e8; border-radius: 12px; padding: 0.9rem 1rem 1rem; margin-bottom: 1rem; }
/* two fields side by side, each a sensible width and LEFT-aligned (not stretched apart across the whole card). */
.zt-rank-form-fields { display: grid; grid-template-columns: minmax(0, 21rem) minmax(0, 21rem); gap: 1rem; justify-content: start; }
.zt-rank-form-actions { display: flex; justify-content: flex-start; gap: 0.6rem; margin-top: 0.9rem; }
/* When the form sits under an identity header (the promote dialog), give it the same breathing room the header
   already has around it — an adjacency rule, so it never needs a per-page class. */
.ztmo + .zt-rank-form { margin-top: 1rem; }

/* MEMBERS SEARCH — HEADER: larger CA logo, GREY title, and NO bottom rule (seamless like the membership editor). */
/* THE HEADER IS A BAND NOW, so it keeps the flush band's own border + padding rather than the seamless
   treatment this card used to have. Only the body's top padding is trimmed, so the filters sit close under it. */
.zt-member-search-card .zt-action-card-body { padding-top: 0.9rem; }
/* ── THE MEMBER-SEARCH IDENTITY: CENTRED, AND SMALLER SO MORE OF THE NAME SURVIVES ─────────────────────────
   Vince, 2026-08-07: "we just centre the connected account name and member search horizontally, and we can
   lower the font size a little bit there too so we could see more of it."

   1.35rem was set when this header also carried a New button on the right; with that button moved to the footer
   the line has the whole width, and dropping to 1.1rem buys roughly four more characters before the ellipsis —
   which on "Gracie Humaita - La Mesa" is the difference between a place and "Gracie Humaita - La…".

   ⚠️ NO LONGER CENTRED (Vince, 2026-08-14: "the header here is in the middle — that should be aligned like the
   normal normal ones"). It WAS centred on 2026-08-07, back when this header was the odd one out; the rest of the
   app then settled on one header shape — logo left, name and subtitle beside it — and a centred one now reads as
   the broken screen rather than the special one. Consistency beat the local optimum, so the three centring rules
   (title text-align, subtitle justify-content, and the flex-line/`.ztmo-body` shrink that made them line up) are
   gone and this inherits the default ZtMediaObject layout like every other header.
   Only the TYPE TREATMENT is still local: grey and 1.1rem, which is about ellipsis budget, not alignment. */
.zt-member-search-head .ztmo-title { color: var(--zeal-medium-grey); font-size: 1.1rem; }
/* FILTERS — 2×2, TIGHT, collapsing to ONE column on narrow screens. The fields fill their column (drop the field
   max-width) and their own 0.75em/0.5em vertical margin is removed, so the ONLY vertical space is the small
   row-gap (that field margin had doubled the gaps Vince flagged). */
.zt-member-search-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 1rem; row-gap: 0.35rem; }
.zt-member-search-fields .zt-input-container { margin: 0; max-width: 100%; }
.zt-member-search-actions { display: flex; flex-wrap: wrap; justify-content: flex-start; gap: 0.6rem; margin-top: 0.9rem; }
/* RESULTS stack TIGHT: the card already carries a bottom margin, so the list adds no extra gap (the old 1rem gap
   + the card's 1em margin stacked to ~2rem = way too far apart). */
.zt-member-results { display: flex; flex-direction: column; gap: 0; margin-top: 0.75rem; }
.zt-member-results .zt-action-card { margin-bottom: 0.6rem; }
.zt-member-results .zt-action-card:last-child { margin-bottom: 0; }
/* the "N members found" count + live filter — a white bar that STICKS to the top while the results scroll under
   it, so the count stays visible and you can keep filtering. */
.zt-member-count { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 0.6rem; margin: 0.75rem 0; padding: 0.55rem 0.9rem; background: var(--zeal-white); border-radius: 8px; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); font-weight: 600; color: var(--zeal-medium-grey); }
/* WHEN STUCK to the top (toggled by ztSticky's IntersectionObserver): square the top corners and lift the shadow
   so the bar reads as a flush, opaque header spanning the full column — the result cards (same width) slide
   cleanly UNDERNEATH it and fully disappear, with nothing peeking above. */
.zt-member-count.is-stuck { border-top-left-radius: 0; border-top-right-radius: 0; box-shadow: 0 7px 16px rgba(0, 0, 0, 0.3); }
.zt-member-count .zt-fa { color: var(--zeal-tiffany); }
.zt-member-count-text { flex: 0 0 auto; }
/* THE TOOLS GROUP on the right of the bar — the live filter, then the small Export button. Grouping them (rather
   than pushing each one right on its own) is what lets the pair drop to its OWN full-width row on a phone with the
   filter growing to fill it, instead of the filter being squeezed to nothing beside the button. */
.zt-member-count-tools { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
/* ⚠️ THE FILTER KEEPS ITS OWN margin-left:auto TOO, and that is not redundant. Inside the tools group it does
   nothing (the group is content-sized, so there is no free space to absorb) — but the stylesheet is a plain file
   on disk while the markup lives in a DLL a running host holds, so the two go live at DIFFERENT MOMENTS. Without
   this the filter jumps to the LEFT of the bar for as long as the old markup is still loaded, which is exactly
   what Vince saw. Right-justified either way. */
.zt-member-filter { margin-left: auto; }
/* The EXPORT itself is a real form POST in the wave editor's FOOTER (a browser can only start a download from a
   navigation), but it must not lay out as a box between the other buttons — display:contents hands its button
   straight to the footer's flex row, exactly like the document download's form. */
.zt-member-export-form { display: contents; }
/* THE EXPORT BUTTON'S ICON, SOLID WHITE — MATCHING ITS LABEL.
   TWO separate things were tinting it, which is why it came out green on a green button:
     1. `.zt-member-count .zt-fa` above paints EVERY icon in this bar tiffany — it was written when the only icon
        in here was the count's, and a button dropped into the bar inherited it. `color: inherit` takes the
        BUTTON's own text colour (white), so the icon and the word "Export" finally match.
     2. A duotone glyph draws its second layer at 0.4 opacity. That is depth on a light background and a
        washed-out smudge on a saturated one, so the secondary layer goes to full opacity here.
   Scoped to this button on purpose: the duotone default and the bar's tiffany tint are both right elsewhere. */
.zt-member-count .zt-member-export-btn .zt-fa,
.zt-member-export-btn .zt-fa { color: inherit; --zt-fa-secondary-opacity: 1; }
/* inline FILTER control (ZtFilterInput): a rounded pill — search icon · input · clear. */
.zt-filter { display: inline-flex; align-items: center; gap: 6px; padding: 0.35rem 0.75rem; background: #f4f5f5; border: 1px solid #e2e5e5; border-radius: 999px; min-width: 170px; max-width: 320px; }
.zt-filter-icon { color: var(--zeal-grey); flex: 0 0 auto; font-size: 0.9rem; }
.zt-filter-input { flex: 1 1 auto; min-width: 0; border: none; outline: none; background: transparent; font-family: inherit; font-size: 0.95rem; color: var(--zeal-dark-grey); }
.zt-filter-clear { background: transparent; border: none; cursor: pointer; color: var(--zeal-grey); display: inline-flex; padding: 0; flex: 0 0 auto; }
.zt-filter-clear:hover { color: var(--zeal-dark-grey); }
@media (max-width: 599px) { .zt-filter { min-width: 120px; } }
/* PHONE WIDTHS: the count bar now carries a count, a filter AND an Export button, which is one thing too many for
   a single row. The tools group takes its own full-width row under the count and the filter grows to fill what the
   button doesn't use — nothing shrinks below its minimum and nothing is clipped. */
@media (max-width: 599px) {
	.zt-member-count { flex-wrap: wrap; row-gap: 0.5rem; }
	.zt-member-count-tools { flex: 1 1 100%; margin-left: 0; }
	.zt-member-count-tools .zt-filter { flex: 1 1 auto; max-width: none; }
}
@media (max-width: 599px) {
	.zt-member-search-fields { grid-template-columns: 1fr; }
}
/* ⚠️ THE COUNT BAR KEEPS ITS CORNERS ON PHONES NOW. It used to square itself at ≤480px to match the full-bleed
   sheet the page was at that width — but sections no longer full-bleed (see --zt-page-gutter), so a square bar
   sitting between rounded cards was the only thing on the page still pretending the old layout existed.
   `.is-stuck` still squares its TOP corners while it is pinned, which is a different thing and still correct. */

/* MEMBER RESULT CARD — the header is a full-bleed GREY band (FilledHeaderFooter) with NO rule under it. */
.zt-member-result .zt-action-card-header { border-bottom: none; }
/* SLIM (ShowPrograms="false" — the admin Manage Users results): the identity header IS the whole card. ZtActionCard
   always renders its body div, so the now-empty one has to be collapsed here or every slim card sits on a blank
   padded strip. display:none rather than padding:0 so it cannot pick up a border or background from anywhere. */
.zt-member-result.is-slim .zt-action-card-body { display: none; }

/* ── SOFT + ROUNDED, NOT SHADOWED (Vince, 2026-08-07, looking at Manage Users on a phone: "more border radius,
   less drop shadow… that's kind of an old school passé thing on the mobile devices"). ───────────────────────
   A stack of cards each casting its own 5px shadow INSIDE another shadowed card is three layers of grey doing
   one job. The separation now comes from a hairline border, which stays crisp at any density, plus a shadow
   small enough to read as lift rather than depth.

   ⚠️ THIS ALSO UN-SQUARES THEM ON A PHONE, deliberately. The global `@media (max-width: 599px)` rule near the top
   of this file squares EVERY .zt-action-card, because a full-bleed section that touches both screen edges must
   not show rounded corners against nothing. These cards are NOT full-bleed — they sit inset inside a section — so
   that reasoning does not apply to them and the corners are what make a stack read as separate cards. Specificity
   (0,2,0) TIES that rule, so this wins on source order alone: it must stay BELOW it in the file. */
.zt-member-results > .zt-member-result {
	border-radius: 12px;
	border: 1px solid #e8eaea;
	box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
	/* ONE rhythm between cards — the same value as the inset around them (see .zt-user-results below). */
	margin-bottom: 0.75rem;
}
.zt-member-results > .zt-member-result:last-child { margin-bottom: 0; }

/* ── PHONE: THE NAV BUTTONS GO LAST (Vince, 2026-08-07: "take the buttons and put them all the way at the bottom
   so that it is the name, phone number, email address, birth date, and then the buttons along the bottom"). ──
   THIS CARD ASKED FOR IT FIRST AND THE WHOLE LIBRARY FOLLOWED. The shared ztmo mobile stack used to be
   title(1) → actions(2) → lines(3), so this card overrode the order by hand. Vince then hit the same thing on
   Upcoming Demos, Contact Us and the Recharge/Payouts headers and made it global, so `.ztmo-actions` is now
   order:4 for EVERY media object and the order here is simply agreeing with the default.

   THE RULE STAYS for its margin-top (the row needs a hair of separation from the last line) and as the record of
   why: a card that is READ before it is acted on cannot have a button row wedged between the name and the phone
   number — it splits the scan in half. */
@media (max-width: 599px) {
	.zt-member-result .ztmo-actions { order: 4; margin-top: 0.15rem; }
}

/* ── ADMIN "Manage Users" RESULTS — ONE INSET, ALL FOUR SIDES (Vince, same pass: "the padding at the top
   underneath where we have the results is a little bit too much… we could probably go, like, one percent wider
   left and right… consistent padding on inside of the results, top left, right, and bottom"). ───────────────
   The top gap was TWO values stacked — the flush body's 0.85rem plus .zt-member-results' own 0.75rem margin — so
   there was ~26px above the first card and ~14px below the last. The margin goes, and the body carries a single
   0.75rem on every side: even top-to-bottom, and ~6px wider each side than the flush default's 1.15rem. */
.zt-user-results > .zt-action-card-body { padding: 0.75rem; }
.zt-user-results .zt-member-results { margin-top: 0; }
/* Each program membership = its own little rounded control: a rounded panel holding a ZtMediaObject (CA logo +
   program name + rank/status lines) with issue/edit buttons on the right. The ztmo handles alignment. The panel is
   TINTED by health: green (ok), grey (archived), yellow (any issue — overdue / no payment / unsigned docs). */
/* ⚠️ THESE ARE ROW-CARDS AND NOW WEAR THE SHARED TONE (Vince, 2026-08-16 — the site-wide "dark accent, not light
   accent" sweep). The three states were built on the pale --zt-*-bd tints plus two literals, so a member's program
   panels on the admin search were the palest version of a treatment that is emphatic everywhere else. Same fills
   as before; the borders are the real accent and each state gets the 4px tab. */
/* ⚠️ COMPACT ON PURPOSE (Vince, 2026-08-19: "if we had hundreds of these, this is rendering a lot to the
   screen — we need to slim this down significantly"). The buttons left the panel in the same pass, so what
   remains is a small logo beside three short lines — sized so a member in four programs reads at a glance
   instead of scrolling. The tint alone now carries an issue; do not grow this panel back. */
.zt-member-programs { display: flex; flex-direction: column; gap: 0.3rem; }
.zt-member-program { border: 1px solid var(--zeal-silver); border-left: 4px solid var(--zeal-silver); border-radius: 8px; padding: 0.3rem 0.7rem; }
.zt-member-program .ztmo-title { font-size: 0.92rem; line-height: 1.25; }
.zt-member-program .ztmo-subtitle { font-size: 0.78rem; margin-top: 0; line-height: 1.3; }
.zt-member-program .ztmo-body { gap: 0.15rem; }
.zt-member-program.is-ok { background: var(--zt-happy-bg); border-color: var(--zeal-green); }
.zt-member-program.is-archived { background: var(--zt-plain-bg); border-color: var(--zeal-silver); }
.zt-member-program.is-issue { background: var(--zt-warn-bg); border-color: var(--zt-gold-accent); }
/* the issue/edit buttons sit in the CONTENT column (as ztmo ChildContent), in a row UNDER the rank/status and
   LEFT-aligned to the program name + line icons. A hair of top spacing beyond the ztmo-content 0.5rem gap.
   order:4 keeps them LAST — on mobile the ztmo restacks its children by `order` (title 1 / actions 2 / subtitle 3),
   so without an explicit order this un-ordered row would default to 0 and jump ABOVE the name; 4 pins it under
   the status on every width. */
.zt-member-program-actions { display: flex; justify-content: flex-start; align-items: center; gap: 0.4rem; margin-top: 0.15rem; order: 4; }

/* ---- ADMIN: MANAGE CONNECTED ACCOUNTS — the search card + the result cards. Same rhythm as the member search,
   so the two search screens read as one place. ---------------------------------------------------------- */
/* ⚠️ THE NAME FIELD KEEPS THE STANDARD FIELD WIDTH (Vince, 2026-08-07: "that name search field is way too wide,
   bro — just the normal normal size is fine"). It briefly carried max-width:100% copied from the member search,
   where four filters share a 2-col grid and each one has to fill its column. This form is ONE field, so the
   shared .zt-input-container max-width is exactly right and there is nothing to override. Only the field's own
   vertical margin is trimmed, so it sits close under the blurb. */
.zt-ca-search-fields .zt-input-container { margin: 0.25rem 0 0; }
/* RESULT ROWS on the shared stack rhythm — the same treatment the document rows take, so the rows sit evenly and
   .ztdc's own 0.5em margins can't double up on the gap. */
.zt-ca-results { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-ca-results > .ztdc { margin-top: 0; margin-bottom: 0; }
/* ⚠️ TWELVE BUTTONS, NOT ONE EDIT BUTTON. .ztdc pins .ztdc-actions absolutely to the right and reserves 52px of
   padding for it — right for a single Edit, hopeless for eleven destinations plus Charge, which would run
   straight off the card edge. So they come back INTO the flex flow as their own wrapping group: still on the
   right, still after the facts, but able to wrap. Exactly what the document row does, for the same reason.
   (The twelve are laid out as TWO ROWS by .zt-ca-enter — see its block above.) */
.zt-ca-result { padding: 10px 14px; column-gap: 12px; }
/* THE TILES KEEP THEIR OWN, TIGHTER GAP. The user-home copy of this bar rides .ztmo-actions' 6px; these labelled
   tiles are wider and were always set at 0.25rem, so the row keeps that rather than inheriting a look that
   belongs to the other card. */
.zt-ca-result .zt-ca-enter,
.zt-ca-result .zt-ca-nav-row { gap: 0.25rem; }
.zt-ca-result .ztdc-actions {
	position: static; transform: none;
	flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 0.25rem; max-width: 100%;
}
/* THE LOGO IS THE ROW'S ANCHOR — much bigger than the ztdc default 48px (Vince, 2026-08-07). At this size the
   business is identifiable before you have read a single word, which is the point of a search result. */
.zt-ca-result .ztdc-avatar { width: 76px; height: 76px; font-size: 30px; }
/* ── TWO COLUMNS OF CONTENT (Vince, 2026-08-07) ────────────────────────────────────────────────────────────
   WHO on the left — name, then email and phone under it (the ztdc MainContent slot); WHERE on the right — the
   address, stacked. Five facts previously shared one 2-col grid, which split them 3/2 with the phone beside the
   street and nothing lining up. Grouping the contact lines WITH the name is what frees the detail column. */
.zt-ca-result .ztdc-main { flex: 0 0 250px; min-width: 0; }
/* THE NAME: SMALL, AND IT NEVER WRAPS. A two-line "Gracie Humaita - Willis TX" set the row's height and pushed
   every fact down with it; the ones that genuinely don't fit get an ellipsis rather than a second line, and
   ZtDetailCard puts the full name in a title tooltip. */
.zt-ca-result .ztdc-title {
	font-size: 0.88rem; line-height: 1.25;
	white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The contact pair sits tight under the name — a hair of space, then the two lines on the row's small type. */
.zt-ca-result-contact { margin-top: 0.2rem; }
/* ONE COLUMN for the address. It is a street, an optional suite and a city line — three parts of ONE thing, so a
   2-col grid would break it across a gutter mid-address. Same single-column call the document row makes. */
.zt-ca-result .ztdc-details { grid-template-columns: minmax(0, 1fr); }
/* THE ROW'S SMALL TYPE — applied to BOTH content columns so the identity lines and the address read as one size
   (they are the same kind of fact, just grouped differently). */
.zt-ca-result .ztdc-main .zt-content-items,
.zt-ca-result .ztdc-details { font-size: 0.82rem; gap: 0.1rem 1rem; }
.zt-ca-result .zt-content-item { gap: 7px; }
.zt-ca-result .zt-content-item-icon { font-size: 0.9rem; }
/* ── PHONE (Vince, 2026-08-07 — "this is definitely a page I'm gonna use on mobile") ───────────────────────
   TWO STACKED BLOCKS, TIGHT: logo + identity SIDE BY SIDE on the first line · the buttons in ONE row under them.

   ⚠️ THE ADDRESS IS HIDDEN AT THIS WIDTH, not moved or restructured — "get rid of the address, let's just
   collapse that… still be the same control, let's not reinvent everything." It is the SAME ZtDetailCard with the
   SAME Details slot; only this one rule differs, so nothing about the desktop row or the markup changes. What a
   phone needs from a search result is who it is and where to tap next; the street address is a desk job.

   ⚠️ THE BANDED HEADER / WHITE BODY / FOOTER TREATMENT IS GONE HERE TOO. The program and document rows take it
   at this width and it suits them, but on a card this short it drew a white box inside a white card — "we can do
   away with that white bodies section". This row is one plain block. */
@media (max-width: 599px) {
	/* THE SECTION'S OWN PADDING COUNTS TOO. The flush card body is 1.15rem each side, and every pixel it spends is
	   a pixel the seven-button row does not have. Trimmed here (results card only) so the row gets ~18px back. */
	.zt-ca-results-card > .zt-action-card-body { padding: 0.7rem 0.6rem; }
	.zt-ca-result { padding: 10px 12px; row-gap: 8px; column-gap: 10px; }
	.zt-ca-result .ztdc-avatar { width: 64px; height: 64px; font-size: 26px; margin: 0; }
	/* ⚠️ `flex-basis: 0` IS WHAT KEEPS THE IDENTITY BESIDE THE LOGO — ALWAYS, at any name length. `min-width: 0`
	   alone was NOT enough and this is the subtlety that cost two rounds: min-width governs how far an item may
	   SHRINK once it is on a line, but flex-wrap decides WHICH LINE it lands on using the item's HYPOTHETICAL size
	   — its flex-basis, which at `auto` is its max-content width. So the longest rows (Gracie Humaita Edwardsville
	   IL / joey.walters@zealproshop.com) measured ~300px, did not fit beside the 64px logo, and wrapped to their
	   own line BEFORE shrinking was ever considered. With basis 0 the hypothetical size is 0: it always fits, then
	   grows into whatever is left, and the name/email/phone ellipsis exactly as they are set up to. */
	.zt-ca-result .ztdc-main { flex: 1 1 0; min-width: 0; align-self: center; margin: 0; }
	/* THE ADDRESS COLLAPSES. Same control, same slot, one rule — see the note above this media query. */
	.zt-ca-result .ztdc-details { display: none; }
	/* ⚠️ SIX TILES PER ROW, AND THEY STAY SQUARE. The tiles keep their fixed 41×41 everywhere else, which wrapped
	   them onto extra lines here; sharing the width (flex: 1 1 0) fixed the wrap but left the HEIGHT pinned at
	   41px, so a 37px-wide tile came out as an upright rectangle. `aspect-ratio: 1` with `height: auto` is the
	   whole fix: whatever width the flex line hands a tile, its height matches.
	   The max-width stops them ballooning into 77px slabs near the 599px end of this range; space-between then
	   spreads each row edge to edge instead of bunching it left. */
	.zt-ca-result .ztdc-actions { flex: 1 1 100%; justify-content: space-between; margin: 0; padding: 0; gap: 4px; }
	/* THE STACK AND ITS TWO ROWS TAKE THE FULL WIDTH, so each row spreads across the card exactly as the single
	   row used to. nowrap keeps a row at SIX — the tiles are already sharing the width, so letting them wrap
	   would break the pairing into three ragged lines on the narrowest phones. */
	.zt-ca-result .zt-ca-enter { width: 100%; align-items: stretch; gap: 4px; }
	.zt-ca-result .zt-ca-nav-row { flex-wrap: nowrap; justify-content: space-between; gap: 4px; }
	.zt-ca-result .ztdc-actions .ztbtn.ztbtn-menu-small {
		flex: 1 1 0; width: auto; min-width: 0; max-width: 60px;
		height: auto; aspect-ratio: 1 / 1;
	}
	/* PHONE WIDTHS: the search header carries a long title AND two labelled actions (Delete · New), which is one
	   thing too many for a single row — the actions take their own line under the title rather than squeezing
	   it. Scoped here on purpose: .zt-section-head is shared and does not wrap by default. */
	.zt-ca-search-card .zt-section-head { flex-wrap: wrap; row-gap: 0.5rem; }
	.zt-ca-search-card .zt-section-actions { flex: 1 1 100%; }
}

/* ---- ZT CREDIT CARD (ZtCreditCard) — a saved card rendered to look like a REAL credit card, with a button bar
   underneath. The list is a responsive grid (1 per row on phones, multi-column when wide). ------------------ */
/* the SECTION (ZtActionCard FilledHeaderFooter) grey header BAND: title (card icon + "Credit Cards") on the left,
   Add button on the right, everything vertically centered — matches the membership-editor header. */
.zt-cc-section { margin-bottom: 0.75em; }
.zt-cc-section-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.zt-cc-section-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; font-size: 1.15rem; font-weight: 700; color: #333; }
.zt-cc-section-title .zt-fa { color: var(--zeal-tiffany); }

/* GENERIC section header band (a FilledHeaderFooter ZtActionCard) — title on the left, action buttons on the
   right, vertically centered. Shared so Family / Credit Cards / … read identically. */
.zt-section-card { margin-bottom: 0.75em; }
.zt-section-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.zt-section-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; font-size: 1.15rem; font-weight: 700; color: #333; }
.zt-section-title .zt-fa { color: var(--zeal-tiffany); }
.zt-section-actions { display: flex; align-items: center; gap: 0.4rem; flex: 0 0 auto; }

/* ═══ CONNECTED-SITE CANVAS BASE — the legacy typography SITE pages render under ═══
   The app UI deliberately wears INTER; the SITES have always worn the legacy body face — "Gotham Light" with
   BROWSER-SYNTHESIZED bold (reboot.scss body). A dedicated single-face family reproduces that trick (the
   multi-weight "Gotham" maps 400 → Book, which reads too heavy). .layout-container exists ONLY on site
   canvases (the public tenant pages + the site editor), so scoping here previews sites in the SITE's face. */
@font-face { font-family: "Gotham Site"; font-weight: 400; font-style: normal; font-display: swap; src: url("../fonts/gotham/gotham-light.woff") format("woff"); }
.layout-container {
	font-family: "Gotham Site", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
	font-size: 1rem; font-weight: 400; line-height: 1.5; color: #333333; text-align: left;
}
.layout-container a { color: #007bff; text-decoration: none; background-color: transparent; }
.layout-container a:hover { color: #0056b3; text-decoration: underline; }
.layout-container p { margin-top: 0; margin-bottom: 1rem; }

/* SITE SECTION CARDS — the legacy page-section.scss BASE the sites have always rendered under (the app's own
   ZtPageSection look — tiffany icon, halved paddings, #555 — is deliberately different). The connected-site
   stylesheet (linked AFTER this file, exactly like the legacy cascade) then overlays its title-centering and
   small-screen rules on top. Vince, 2026-08-11: grey title icons, real headroom above the title. */
.layout-container .zt-page-section {
	width: 100%; max-width: 100%; background: #ffffff; border-radius: 5px;
	padding: 1.25em 1.5em; color: #666666; margin: 0 auto 1em;
	box-sizing: border-box; position: relative; box-shadow: none;
}
.layout-container .zt-page-section-title {
	font-size: 1.25rem; line-height: 1.25rem; font-weight: bold; color: #666666;
	/* THE HEADROOM — legacy padding-top 0.55em between the card edge and the title. */
	padding-top: 0.55em; margin-bottom: 0.5em; gap: 0.25em;
}
.layout-container .zt-page-section-title-icon { color: #666666; }
@media only screen and (max-width: 599px) {
	.layout-container .zt-page-section { max-width: 94%; width: 94%; padding: 1.5vh 3vw; }
}
@media only screen and (max-width: 599px) {
	.layout-container .zt-page-section { max-width: 99%; width: 99%; }
}

/* ═══ CONNECTED SITE EDITOR — the legacy ZtConnectedSite editor screen (ZtConnectedSiteEditor) ═══
   The canvas renders IN PAGE FLOW — the connected-site-default stylesheet's .layout-container IS the legacy
   grey gutter — and the SHELL steps aside via PURE CSS (:has), NO JS body class: a JS-managed class raced
   enhanced navigation (the old island disposed after the new one entered → the toolbar un-fixed mid-swap).
   Doubled classes out-specificity the scoped ZtShellLayout rules regardless of stylesheet load order. */
.zt-shell-page.zt-shell-page:has(.zt-site-editor) { padding: 0; }
.zt-shell-content.zt-shell-content:has(.zt-site-editor) { max-width: none; margin: 0; }
/* THE TRANSITION WRAPPER'S transform MAKES IT THE CONTAINING BLOCK FOR fixed DESCENDANTS (the documented
   "not centered" trap) — neutralized whenever the editor is in its tree, so the toolbar docks to the viewport. */
.zt-page-transition:has(.zt-site-editor) { animation: none !important; transform: none !important; perspective: none !important; }

.zt-site-editor .layout-container {
	box-sizing: border-box; min-height: 100dvh;
	/* THE LEGACY EDITOR RESERVATION — content clears the fixed toolbar + the nav bar below it. */
	padding: 20px 20px calc(var(--zt-navbar-h, 60px) + 110px);
}
/* PREVIEW — no toolbar, so the canvas only clears the nav bar. */
.zt-site-preview .layout-container { padding-bottom: calc(var(--zt-navbar-h, 60px) + 40px); }

/* ⚠️ THE PUBLIC TENANT PAGE — THE LAYOUT OWNS THE PAGE GUTTER, AND IT IS THE SAME GUTTER ON EVERY PAGE TYPE.
   The bottom clears the fixed house nav bar; the other three sides are the site's breathing room. Doubled
   specificity beats the connected-site-default `padding-bottom: 55px`, which loads after this file.

   THIS IS THE BUG THAT COST TWO ROUNDS. The rule used to set `padding-bottom` ONLY, so the public tenant
   container had no gutter at all while the builder canvas did (`.zt-site-editor .layout-container` →
   `padding: 20px 20px …`). Every tenant page therefore rendered edge-to-edge on the live domain and correctly
   inset in the editor — the same defect surfacing twice, first on albums (full-bleed, so it was obvious) and
   then on content pages, where the white `.zt-page-section` card ran hard against x=0 and y=0 with no grey
   showing at all (Vince, 2026-08-14: "we lost the padding around these sections").

   ⚠️ DO NOT PUT THIS GUTTER BACK ON A PAGE TYPE. The first album fix padded `.zt-album-scope` instead of the
   container, which fixed albums and left every content page still broken — and it would have had to be
   repeated for each new page type. THE GUTTER IS A PROPERTY OF THE LAYOUT, so it belongs on the layout's
   container exactly once. That is also what makes the named-layout library workable: a layout swaps this one
   rule and every page type follows, instead of each page type carrying its own copy of the site's geometry.
   (The `.zt-tenant-nav-top` variant below overrides only top/bottom on purpose — it inherits these sides.)

   THE GUTTER IS `--zt-page-gutter`, NOT A HARD-CODED 20px. That token is the app's existing page rhythm
   (16px, stepping to 12px at ≤480px), so a tenant site now insets on the same rhythm as every other page
   instead of inventing a second one. It also matters on a phone: `.zt-page-section` still carries the legacy
   per-card inset (94% / 99% at COMPACT), so a container gutter ADDS to it — a flat 20px cost a 375px
   screen 39px of card width, where 12px keeps the floating look at half the price. */
.layout-container.zt-tenant-public {
	padding: var(--zt-page-gutter, 16px) var(--zt-page-gutter, 16px) calc(var(--zt-navbar-h, 60px) + 40px);
}

/* 🎨 AN ALBUM PAGE IS ONE COLOUR — THE DARK ONE. Two greys met on any album page short enough not to fill the
   viewport: the site theme paints `.layout-container` #777 (connected-site-default), the shell paints `body`
   #333 (site.css), and the container only grows to its CONTENT height — so a two-album index ended at ~460px
   and the rest of the screen went dark. A visible horizontal seam across the page (Vince, 2026-08-14:
   "two different gray colors… I prefer the darker color").

   MATCHING THE COLOUR IS THE WHOLE FIX — no min-height needed. `html` has no background, so `body`'s #333
   propagates to the entire canvas; once the container agrees, there is nothing left to see a seam between,
   at any content height.

   SCOPED WITH `:has()` SO ONLY ALBUM PAGES GO DARK. #777 is the site theme's page colour and the ordinary
   content pages need it — their white `.zt-page-section` cards are designed to sit ON it. That is safe here
   because an album surface is never mixed with content sections: `ZtTenantSiteView` renders albums through
   their own whole-page slots (AlbumContent / AlbumsPageContent), so a page has either albums or sections,
   never both. Specificity 0,3,0 beats the theme's `.layout-container` (0,1,0) even though it loads later. */
.layout-container.zt-tenant-public:has(.zt-album-scope) { background-color: #333333; }

/* PER-SITE NAV STYLING (the public tenant path) — the ConnectedSites Nav* columns ride CSS custom properties
   set inline by TenantSiteHost on the .zt-tenant-nav wrapper (a fixed bar still inherits from its DOM parent);
   unset colors resolve server-side to the default dark-bar values, so the vars are always defined here. */
.zt-tenant-nav .zt-navbar { background: var(--zt-tenant-nav-bar, var(--zeal-dark-grey, #2b2b2b)); }
.zt-tenant-nav .zt-navbar-ctx-btn,
.zt-tenant-nav .zt-navmenu-btn {
	background: var(--zt-tenant-nav-btn, rgba(255, 255, 255, 0.20));
	color: var(--zt-tenant-nav-btn-fg, #ffffff);
}
/* THE ICON BUTTONS (hamburger, fullscreen) FOLLOW THE INK TOO. Their base rule is a hard `color: #fff`,
   which is invisible the moment a site captures a LIGHT bar — Vince had to hover the three-lines button
   to find it on Wentzville's white bar (2026-08-19). The harvested button-text colour is by definition
   readable on the harvested bar (the capture guards it), so it is the right colour for every glyph that
   sits directly ON the bar. */
.zt-tenant-nav .zt-navbar-icon-btn { color: var(--zt-tenant-nav-btn-fg, #ffffff); }
/* NAV BAR AT TOP — the site's NavPosition="top": the bar docks to the TOP edge, the drawer opens BELOW it,
   and the page content clears the top of the viewport instead of the bottom. */
.zt-tenant-nav-top .zt-navbar { top: 0; bottom: auto; border-top: none; border-bottom: 1px solid #444444; }
.zt-tenant-nav-top .zt-navmenu { top: var(--zt-navbar-h, 60px); bottom: 0; transform-origin: top center; }
body:has(.zt-tenant-nav-top) .layout-container.zt-tenant-public {
	padding-top: calc(var(--zt-navbar-h, 60px) + 20px);
	padding-bottom: 40px;
}
/* SAME CLEARANCE FOR AN IMPORTED SITE — it renders in .zt-imported-host, but our fixed bar still covers it.
   TOP EXACTLY, BOTTOM ZERO: with the bar at the top edge nothing of ours covers the foot, and any padding
   there paints as a white strip of their body colour below their own footer (the 2026-08-19 gap bug). */
body:has(.zt-tenant-nav-top) .zt-imported-host.zt-tenant-public {
	padding-top: var(--zt-navbar-h, 60px);
	padding-bottom: 0;
}

/* ═══ THE SAME PER-SITE NAV STYLING, OFF THE DOCUMENT ROOT — THE EDITOR + PREVIEW PATH ═══
   The block above only works on the LIVE tenant host, where TenantSiteHost wraps the nav island in
   .zt-tenant-nav and sets the custom properties inline. The EDITOR (ZtConnectedSiteEditor) and the PREVIEW
   (ConnectedSitePreviewSection) render inside the app shell and reuse the SHELL'S nav bar, which is a
   SIBLING of the editor content — so no wrapper of theirs can reach it: custom properties inherit DOWN, and
   a descendant selector needs an actual ancestor. ztTenantNav.apply() puts the three properties on
   documentElement and these classes on <body>, which IS an ancestor of both trees.

   ⚠️ THE CLASS NAMES ARE NOT `zt-tenant-nav-top`. That one is the live host's WRAPPER class and the rules
   above key `body:has()` off it; putting it on <body> itself would break those silently (`:has` matches
   descendants, and body is not its own descendant). Two names, two explicit twins, no overlap. */
body.zt-tenant-nav-doc .zt-navbar { background: var(--zt-tenant-nav-bar, var(--zeal-dark-grey, #2b2b2b)); }
body.zt-tenant-nav-doc .zt-navbar-ctx-btn,
body.zt-tenant-nav-doc .zt-navmenu-btn {
	background: var(--zt-tenant-nav-btn, rgba(255, 255, 255, 0.20));
	color: var(--zt-tenant-nav-btn-fg, #ffffff);
}
/* SAME INK FOR THE ICON BUTTONS IN THE EDITOR/PREVIEW — the twin of the rule above. */
body.zt-tenant-nav-doc .zt-navbar-icon-btn { color: var(--zt-tenant-nav-btn-fg, #ffffff); }
/* NAV BAR AT TOP, IN THE EDITOR/PREVIEW — the twin of `.zt-tenant-nav-top .zt-navbar` above. */
body.zt-tenant-nav-doc-top .zt-navbar { top: 0; bottom: auto; border-top: none; border-bottom: 1px solid #444444; }
body.zt-tenant-nav-doc-top .zt-navmenu { top: var(--zt-navbar-h, 60px); bottom: 0; transform-origin: top center; }

/* THE BUILDER TOOLBAR RIDES WITH THE BAR. It docks `bottom: var(--zt-navbar-h)` so it sits just above the
   nav; once the nav moves to the TOP edge that reservation is a 60px strip of nothing, with the toolbar
   floating above it. Docking it to the viewport floor is the whole correction. */
body.zt-tenant-nav-doc-top .zt-site-editor .site-builder-toolbar { bottom: 0; }

/* AND THE CANVAS CLEARS THE OTHER END. Same reservation swap the public tenant container makes above: the
   bar no longer covers the bottom of the canvas, it covers the top. Specificity 0,3,1 beats the 0,2,0 base
   rules (`.zt-site-editor .layout-container`, `.zt-site-preview .zt-imported-host`) regardless of order. */
body.zt-tenant-nav-doc-top .zt-site-editor .layout-container {
	padding-top: calc(var(--zt-navbar-h, 60px) + 20px);
	padding-bottom: 80px;
}
body.zt-tenant-nav-doc-top .zt-site-preview .layout-container {
	padding-top: calc(var(--zt-navbar-h, 60px) + 20px);
	padding-bottom: 20px;
}
/* IMPORTED SITES SIT FLUSH — the +20px breathing above belongs to OUR grey canvas, where it reads as the
   house gutter. On an imported page it paints as a bare strip of their body colour between our bar and
   their hero (the 2026-08-19 gap bug), so the imported host reserves the chrome and NOTHING else: bar
   height at the head; at the foot just the toolbar in the editor (it docks to the floor in top mode) and
   zero in the preview, where nothing of ours covers the bottom at all. */
body.zt-tenant-nav-doc-top .zt-site-editor .zt-imported-host {
	padding-top: var(--zt-navbar-h, 60px);
	padding-bottom: 60px;
}
body.zt-tenant-nav-doc-top .zt-site-preview .zt-imported-host {
	padding-top: var(--zt-navbar-h, 60px);
	padding-bottom: 0;
}

/* THE BUILDER TOOLBAR — ported literally from ZtConnectedSite.razor.scss (.site-builder-toolbar): fixed just
   above the platform nav bar, dark, grouped with divider rules. Plus horizontal scrolling so every button stays
   reachable on a phone (the legacy bar just clipped). Below the navbar (9000) and the wave editors (9500). */
.zt-site-editor .site-builder-toolbar {
	position: fixed; left: 0; right: 0; bottom: var(--zt-navbar-h, 60px); min-height: 60px; box-sizing: border-box;
	background: #333333; border-top: 1pt solid #444444; z-index: 8000;
	display: flex; align-items: center; gap: 5px; padding: 10px;
	overflow-x: auto; scrollbar-width: none;
}
.zt-site-editor .site-builder-toolbar::-webkit-scrollbar { display: none; }
.site-builder-group { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.site-builder-group + .site-builder-group { margin-left: 6px; padding-left: 11px; border-left: 1px solid #555555; }

/* PAGE-EDITOR CARDS (type badge + security card) — stacked with the form inputs' vertical rhythm. */
.zt-page-editor-cards { display: flex; flex-direction: column; gap: 10px; margin: 8px 0; }

/* ARCHIVED SECTION (EDITOR CANVAS ONLY) — the legacy light-yellow wash, made a touch more obvious per Vince
   (2026-08-12: "some opacity over them… maybe like a yellow tint"): a gold-tinted card, FADED content, and a
   gold "Archived" chip on the name plate. The builder bar stays fully opaque so Restore/Delete read clearly. */
.zt-section-archived .zt-page-section {
	background: #fff9e6;
	box-shadow: inset 0 0 0 2px rgba(255, 203, 4, 0.45);
}
.zt-section-archived .zt-tenant-section-content,
.zt-section-archived .zt-page-section-title { opacity: 0.45; }
.zt-section-archived .button-bar { opacity: 1; }
.button-bar-archived {
	display: inline-flex; align-items: center; gap: 5px;
	margin-left: 8px; padding: 1px 9px; border-radius: 999px;
	background: var(--zeal-gold, #ffcb04); color: #4a3d00;
	font-size: 8pt; font-weight: 700; letter-spacing: 0.03em;
}
/* THE NAME PLATE HOLDS THE ARCHIVED CHIP TOO — flex centers the chip against the title vertically
   (baseline alignment left it riding high — Vince, 2026-08-12). */
.zt-section-archived .button-bar-title { display: flex; align-items: center; }

/* AN INERT identity chip (tenant screens' user chip) — displays, but nothing about it says "click me". */
.zt-navbar-identity-inert { cursor: default; }

/* ARCHIVED site pages/sections on the EDITOR nav (bar + drawer) — gold-washed + a gold ring so the state
   reads at a glance; public/preview navs never carry archived items at all. DOUBLED CLASS on purpose: the
   is-active rules (#aaa, later in this file) tied the single-class version and erased the gold on the very
   page being looked at (Vince, 2026-08-12). The ring survives every state, active included. */
.zt-navbar-ctx-btn.zt-nav-archived.zt-nav-archived,
.zt-navmenu-btn.zt-nav-archived.zt-nav-archived {
	background: rgba(255, 203, 4, 0.30);
	box-shadow: inset 0 0 0 2px var(--zeal-gold, #ffcb04);
	opacity: 0.85;
}

/* PAGES PANEL — COMPACT rows (Vince, 2026-08-12: icon · name · one status pill, nothing else): tighter
   card padding/margins, ~10% smaller icon + title, real air between the icon and the name, and NO Manage
   chip — the whole row already opens the page. */
.zt-pages-hint {
	/* THE zt-wave-note SPACING TRICK — above is the body's padding-top (1.15rem), below is the body's flex
	   gap (--zt-wave-gap); the hint pays the difference so the two gaps read EQUAL. */
	margin: 0 0 calc(1.15rem - var(--zt-wave-gap, 0.85rem));
	font-size: 9.5pt; line-height: 1.5; color: #666666;
}
.zt-pages-list { display: flex; flex-direction: column; gap: 5px; margin: 0; }
.zt-pages-list .ztic { margin: 0; padding: 5px 12px; align-items: center; gap: 12px; }
.zt-pages-list .ztic-action { display: none; }
.zt-pages-list .ztic-header { align-items: center; }
.zt-pages-list .ztic-icon { font-size: 1.2rem; }     /* the 2em disc scales with it — ~10% smaller */
.zt-pages-list .ztic-title { font-size: 0.88rem; line-height: 1.3; }

/* ZtInputIcon — the icon-picker input (site page icons): a green current-icon tile + name, and the
   filterable picker grid inside its ZtModal. */
.zt-input-icon-row { display: flex; align-items: center; gap: 10px; }
.zt-input-icon-current {
	display: inline-flex; align-items: center; justify-content: center;
	width: 44px; height: 44px; border: 0; border-radius: 8px; cursor: pointer;
	background: var(--zeal-green); color: #ffffff; font-size: 1.25rem;
}
.zt-input-icon-name { font-weight: 600; color: var(--zeal-dark-grey); }
.zt-icon-picker-filter input {
	width: 100%; box-sizing: border-box; padding: 8px 10px; margin-bottom: 10px;
	border: 1px solid #dddddd; border-radius: 8px; font-size: 1rem; font-family: inherit;
}
.zt-icon-picker-grid {
	display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 6px;
	max-height: 50vh; overflow-y: auto;
}
.zt-icon-picker-tile {
	display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 4px;
	border: 1px solid #eeeeee; border-radius: 8px; background: #ffffff; cursor: pointer;
	font-size: 0.65rem; color: #555555; font-family: inherit; min-width: 0;
}
.zt-icon-picker-tile span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-icon-picker-tile .zt-fa { font-size: 1.2rem; color: var(--zeal-dark-grey); }
.zt-icon-picker-tile.is-active { border-color: var(--zeal-green); background: var(--zeal-light-green); }
.zt-icon-picker-more { grid-column: 1 / -1; text-align: center; color: #888888; font-size: 0.8rem; padding: 6px; }

/* PER-SECTION BUILDER BAR — ported from the legacy ZtConnectedSection.razor.scss (.button-bar): the grey
   plate under each section with the dark name plate and the menu buttons. */
.zt-tenant-section .button-bar {
	background-color: #eeeeee; padding: 10px; border-radius: 5px; max-width: 300px; margin-top: 8px;
	display: flex; align-items: center; flex-wrap: wrap; gap: 5px;
}
.zt-tenant-section .button-bar-title {
	flex: 1 1 100%; font-weight: bold; color: #ffffff; padding: 3px 3px 3px 10px;
	background-color: #666666; border-radius: 4px; margin-bottom: 4px;
	overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
/* ── OPT-IN: `zt-compact-actions` on a section card ────────────────────────────────────────────────────────
   ON A PHONE, this section's header buttons DROP THEIR LABELS and collapse to icon-only squares, so they stop
   crowding the section title. Vince, 2026-08-07, on the connected home's Accounts section: "let's please make
   the refresh button and add button icon only."

   ⚠️ IT IS OPT-IN, NOT THE DEFAULT, AND THAT IS DELIBERATE. The house rule is that a section's header actions
   are REAL LABELLED buttons whenever the icon alone does not say what they do — "especially anything
   destructive" (see the section-header rule). Refresh (a circular arrow) and Add (a plus) are universally
   understood and safe; a Delete is neither, and must never be discovered by hovering. So a section asks for
   this by name rather than getting it for free, and one with a destructive header action simply does not ask.

   Each button keeps its accessible Label, so the tooltip and the screen-reader name still say the verb out loud.
   The square-collapse shape is the same one the family/program card actions use. */
@media (max-width: 599px) {
	.zt-compact-actions .zt-section-actions .ztbtn-text { display: none; }
	/* Target .ztbtn, not .ztbtn-icon: these are ZtSmallButtons, whose pill padding otherwise survives the hidden
	   label and leaves a stretched box around a lone glyph. */
	.zt-compact-actions .zt-section-actions .ztbtn {
		width: var(--zt-btn-compact-h); min-width: var(--zt-btn-compact-h);
		height: var(--zt-btn-compact-h); min-height: var(--zt-btn-compact-h);
		padding: 0; gap: 0; justify-content: center;
	}
}
/* FAMILY member cards stacked inside the section (each its own grey-header identity card). */
/* ON THE SHARED STACK RHYTHM — was 0.7rem, which made the gap between family members visibly tighter than the
   gap between programs on the very same page. Same token now, so the two sections breathe identically. */
.zt-family-list { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
/* ZERO THE CARDS' OWN VERTICAL MARGINS so the list's gap is the ONLY thing spacing them — otherwise each
   member card adds .zt-action-card's bottom margin on top of the gap, which is what made the family stack sit
   noticeably looser than the programs stack (and left a bigger gap under the last card than above the first).

   SCOPED THROUGH THE LIST (0,1,1) ON PURPOSE. The old rule here was a bare `.zt-family-member` (0,1,0), which
   ties with `.zt-action-card` — and that base rule is declared ~650 lines LATER, so it won every time. This
   override had never actually done anything. */
.zt-family-list > .zt-family-member { margin-top: 0; margin-bottom: 0; }

/* GLOBAL HAPPY / WARNING treatment (the Blazer trial-card look) — a FilledHeaderFooter card gets a light-GREEN
   ("happy") or pale-YELLOW ("warning") header band + a thin matching border. ONE reusable pair; section/item logic
   decides which each gets. Colours come from the --zt-happy-* / --zt-warn-* vars so every surface stays in sync. */
/* CONNECTED-ACCOUNT PROGRAM CARD — the two system letters sit apart from the program's facts above them, and the
   editable ones are LINKS, so they carry the underline that says so (the legacy set zt-font-underline on exactly
   these two lines). Only the clickable variant is underlined: when the operator may not edit them the same line
   is a statement of fact, not somewhere to go. */
/* AN ISSUE LINE IS A STATEMENT, NOT A LINK — no underline (Vince, 2026-08-04). The small help circle beside it
   is the affordance; the text reads like every other fact on the card. */
.zt-ca-program-issue .zt-content-item-value { display: inline-flex; align-items: center; gap: 0.45rem; }
/* The help circle: small, round, quiet — it must not compete with the line it belongs to. */
.zt-ca-issue-help.ztbtn.ztbtn-icon {
	width: 1.15rem; min-width: 1.15rem; height: 1.15rem; min-height: 1.15rem;
	padding: 0; border-radius: 50%; font-size: 0.7rem;
	background: transparent; border-color: transparent; color: var(--zeal-medium-grey); box-shadow: none;
}
.zt-ca-issue-help.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-red); filter: none; }

/* PROGRAM SCHEDULE — a section per day, a ZtDetailCard per class time inside it.

   THE SLOT STACK, ON THE SHARED RHYTHM — the SAME fix .zt-programs-section already uses, for the same reason.
   .ztdc carries its own 0.5em vertical margins, so as plain block children the space ABOVE the first slot was
   body padding + margin, BETWEEN slots was the collapsed margin alone, and BELOW the last was body padding only
   (.zt-action-card-body > :last-child zeroes the final margin) — three different gaps, and the bottom visibly
   tighter than the top (Vince, 2026-08-05). Flex column + one gap + no card margins makes top, between and
   bottom identical. Do not re-solve this with per-slot margins; use the rhythm token. */
.zt-schedule-day > .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-schedule-day > .zt-action-card-body > .ztdc { margin-top: 0; margin-bottom: 0; }

/* THE TIME IS THE POINT OF THE ROW, so its clock leads at a size you can scan down a column of (Vince,
   2026-08-05). Everything that DESCRIBES the class — name, space, instructor, duration, capacity — steps back a
   little so the times stay the thing your eye lands on first. Scoped to the schedule; other ztdc cards are
   unchanged. */
/* SAME TIGHTER DISC AS THE FACILITY / AGREEMENT CARDS — this slot scales the icon to the same 2.1rem, so it had
   the same too-loose circle. Matched here rather than left to drift. */
.zt-schedule-slot .ztdc-icon { font-size: 2.1rem; width: 1.8em; height: 1.8em; }
.zt-schedule-slot .ztdc-details { font-size: 0.85rem; }
.zt-schedule-slot .ztdc-details .zt-content-item-icon { font-size: 0.9em; }

/* ── THE PUBLIC CLASS SCHEDULE (ZtConnectedScheduleView) ───────────────────────────────────────────────────
   The connected SITE's own schedule page — a header card, then one section card per program holding day
   headings and a ZtDetailCard per class time. It sits NEXT TO the admin schedule above on purpose: both tune
   the same two controls, and a rule written for one is usually wanted by the other.

   ⚠️ ONE GAP, AND ONLY ONE — THE SAME FIX THE ADMIN SCHEDULE DOCUMENTS ABOVE, for the same reason and with the
   same warning. FOUR different margins were stacking on top of the container's gap here: .zt-action-card
   (1em), .zt-section-card (0.75em), .ztdc (0.5em) and .zt-page-content (0.9em). Nothing was wrong with any of
   them individually — they are what those controls do when they stand alone — but added to a flex gap they put
   the better part of a card's height between one program and the next (Vince, 2026-08-14: "way too much space
   between each of the programs"). The CONTAINER'S GAP IS THE SPACING; every child's own margin is zeroed.
   Do not re-solve this with per-card margins. */
.zt-cps {
	display: flex; flex-direction: column; gap: var(--zt-stack-gap); width: 100%; min-width: 0;

	/* ⚠️ THE READING COLUMN — EVERY ROW INSIDE A PROGRAM STOPS HERE (Vince, 2026-08-14: the day strips and the
	   class rows were "way too wide"; he pointed at the connected-account home's trial-class cards as the width
	   he wanted, which measure ~650px on a full-width desktop window).

	   A DAY HEADING AND A CLASS ROW ARE NOT PAGE-WIDTH THINGS. Stretched across a 1900px monitor, "Monday" and
	   its "1 class" end up a foot apart with nothing between them, and the ⓘ pinned to the far right edge is
	   nowhere near the class it belongs to. The CARD still spans the page — it is the container — but its
	   CONTENTS read in a column.

	   640px IS THE SITE'S OWN MEASURE, NOT A NEW NUMBER: connected-site-default.min.css already sets exactly
	   this on `.layout-container p` and `.zt-page-section-title` for owner-authored copy. Matching it is what
	   lines the schedule up with the rest of the tenant site. It is declared HERE (rather than inherited from
	   that sheet) so the page reads identically in the builder PREVIEW, which does not load it. */
	--zt-cps-measure: 640px;
}
.zt-cps > .zt-action-card { margin-bottom: 0; }

/* THE CARD BODIES ARE THE SAME STACK — welcome copy, description, day headings and class times, one rhythm. */
.zt-cps-header > .zt-action-card-body,
.zt-cps-program > .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-cps-header > .zt-action-card-body > .zt-page-content,
.zt-cps-program > .zt-action-card-body > .zt-page-content { margin-bottom: 0; }
.zt-cps-program > .zt-action-card-body > .ztdc { margin-top: 0; margin-bottom: 0; }

/* THE DAY HEADING — a heading inside the program card, on the plain tone so it separates its own classes from
   the day above without becoming a card of its own.

   THE HALF-RHYTHM ABOVE IT IS THE GROUPING. A day belongs to the classes UNDER it; with the container's single
   uniform gap the whole body read as one flat list of equally-spaced things, and you could not see where
   Monday ended and Tuesday began. Half a rhythm extra above the heading is the whole difference. */
.zt-cps-day {
	display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.5em;
	margin-top: calc(var(--zt-stack-gap) * 0.5);
	padding: 0.4em 0.65em;
	border-radius: var(--zeal-radius);
	background: var(--zt-plain-bg);
	font-weight: 700;
	max-width: var(--zt-cps-measure);
}
/* A program whose first child IS a day (no description) starts tight against the header band. */
.zt-cps-program > .zt-action-card-body > .zt-cps-day:first-child { margin-top: 0; }
.zt-cps-day-name { font-size: 1.05rem; min-width: 0; }
/* "3 classes" — a quiet count, not a second heading. */
.zt-cps-day-count { margin-left: auto; font-size: 0.8rem; font-weight: 600; opacity: 0.6; white-space: nowrap; }

/* ⚠️ THE PROSE IS LEFT-ALIGNED, AND IT TAKES TWO CLASSES TO SAY SO. The tenant layout sheet
   (connected-site-default.min.css) carries `.layout-container p { max-width: 599px; margin-left:auto;
   margin-right:auto; }` — a legacy rule that gives OWNER-AUTHORED section copy a readable centred column, and
   it lands on every <p> on the site including these two. That is what floated the welcome copy and the program
   descriptions into the middle of their cards (Vince, 2026-08-14: "the text is centered instead of aligned on
   the left").

   ONLY THE CENTRING IS UNDONE, NOT THE MEASURE: the auto margins go, the site's own 640px max-width stays, so
   the copy still reads at a sane line length instead of running the full width of a desktop card.

   TWO CLASSES BECAUSE OF THE CASCADE, NOT FOR EMPHASIS. `.layout-container p` is (0,1,1) and the tenant sheet
   is linked INSIDE the page — after this one — so a bare `.zt-cps-intro` (0,1,0) loses on both counts.
   `.zt-cps` + the class is (0,2,0) and wins on specificity regardless of order. */
.zt-cps .zt-cps-intro,
.zt-cps .zt-cps-desc { margin-left: 0; margin-right: 0; max-width: var(--zt-cps-measure); }
.zt-cps-intro { margin: 0; line-height: 1.55; }
.zt-cps-desc { margin: 0; line-height: 1.5; opacity: 0.85; }

/* ONE CLASS ROW — capped on the same column as the day heading above it, so the ⓘ pinned to the row's right
   edge lands beside its class instead of out at the edge of the monitor. */
.zt-cps-slot { max-width: var(--zt-cps-measure); }

/* ⚠️ THE FACTS GET THE WHOLE WIDTH — ONE COLUMN, NOT THE SHARED TWO (Vince, 2026-08-14: the coach's name was
   ellipsised to "Coach Krista A…" with "a lot of space there between that column and the info button").

   THE ELLIPSIS WAS NOT THE BUG, THE GRID WAS. `.ztdc-details` is `repeat(2, minmax(0, 1fr))`, and a Details
   slot filled with a <ZtContentItems> group hands it exactly ONE child — so every fact stacked inside the
   FIRST column and the second sat empty. The facts were living in half the room they appeared to have, and
   `.zt-content-item-value`'s nowrap + ellipsis then did its job on a box half the size it looked. Same
   single-column treatment the family, payout and connected-account cards already take, for the same reason. */
.zt-cps-slot .ztdc-details { grid-template-columns: minmax(0, 1fr); }

/* THE TRIAL CTA KEEPS ITS CONTENT WIDTH. In a flex COLUMN a bare button stretches edge to edge, which turns
   the legacy's modest "schedule a trial class" invitation into a full-width green bar across the card. */
.zt-cps-trial { align-self: flex-start; }

/* THE MOBILE-ONLY TIME FACT — hidden while the card still has its title column. Stated BEFORE the media query
   below so the query (same 0,1,0 specificity) wins by order. */
.zt-cps-time { display: none; }

/* ── PHONE ────────────────────────────────────────────────────────────────────────────────────────────────
   the COMPACT line, NOT a bespoke one: this has to fire exactly where `.ztdc` itself wraps to a single
   column, because that wrap is what these rules are answering. */
@media (max-width: 599px) {
	/* ⚠️ ONCE THE CARD WRAPS, THE TIME BECOMES A FACT LIKE ANY OTHER (Vince, 2026-08-14: "the time slot needs
	   to be a regular content item just like the program name, coach name, space name"). On a wide card the
	   time is the TITLE — its own left-hand column with the clock disc beside it, and the thing you scan a
	   schedule for. Wrapped to one column that same title is a lone heading row with a circle floating next to
	   it, and the facts underneath read as a separate list belonging to nothing. So the title block and its
	   disc go, and the duplicate ZtContentItem in the markup takes over.

	   THIS IS THE CALL .zt-settings-row ALREADY MAKES for its leading icon, in the same words: on a wide row
	   the disc is the anchor your eye starts from; once the card is one column it is a full-width band holding
	   one small circle and a lot of empty space. */
	.zt-cps-slot .ztdc-main,
	.zt-cps-slot .ztdc-icon { display: none; }
	.zt-cps-time { display: flex; }

	/* The day heading gives back a little padding at this width; the count keeps its place beside the day when
	   it fits and wraps under it when it does not (margin-left:auto still right-aligns it either way). */
	.zt-cps-day { padding: 0.35em 0.5em; }
	.zt-cps-day-name { font-size: 1rem; }
	.zt-cps-day-count { font-size: 0.75rem; }
}

/* The per-day COPY affordance sits on the day's title row and must stay quiet — it is a shortcut, not the
   point of the card. Same restrained treatment as the issue-help circle. */
.zt-schedule-copy.ztbtn.ztbtn-icon {
	background: transparent; border-color: transparent; box-shadow: none;
	color: var(--zeal-medium-grey);
}
.zt-schedule-copy.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-blue); filter: none; }

/* (The copy dialog's six destination buttons are gone — copying is a wave editor with a ZtMultiSelect now, so
   several days are chosen at once and Save applies them together.) */

/* PROGRAM-EDITOR ALERTS — ONE LOOK, AND THEY CLOSE RATHER THAN DISAPPEAR.
   Both alerts on the program editor (the errors above the form, the running total under the payout list) report the
   same fact, so they are the same small warning at the same rhythm as every input (.zt-input-container = 0.75em).
   The wrapper animates grid-rows 1fr→0fr — the same trick .zt-collapse uses — so resolving the split visibly closes
   the warning instead of deleting it mid-frame, which reads as a glitch. */
.zt-program-alert { margin-bottom: 0; }

.zt-program-alerts {
	display: grid; grid-template-rows: 1fr; opacity: 1;
	margin-bottom: 0.75em;
	transition: grid-template-rows 0.3s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.22s ease, margin-bottom 0.3s ease;
}
.zt-program-alerts-inner { min-height: 0; overflow: hidden; }
.zt-program-alerts.zt-alerts-gone { grid-template-rows: 0fr; opacity: 0; margin-bottom: 0; }

.zt-ca-program-letters { margin-top: 0.75rem; }

/* A LETTER LINE IS A STATEMENT, NOT A LINK — no underline (Vince, 2026-08-05). The tiny pencil beside it is the
   affordance; the text reads like every other fact on the card. Same shape as the issue lines above. */
.zt-ca-program-letter .zt-content-item-value { display: inline-flex; align-items: center; gap: 0.45rem; }
.zt-ca-letter-edit.ztbtn.ztbtn-icon {
	width: 1.15rem; min-width: 1.15rem; height: 1.15rem; min-height: 1.15rem;
	padding: 0; border-radius: 50%; font-size: 0.7rem;
	background: transparent; border-color: transparent; color: var(--zeal-medium-grey); box-shadow: none;
}
.zt-ca-letter-edit.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-green); filter: none; }

/* ═══ FACILITY + AGREEMENT CARDS ════════════════════════════════════════════════════════════════════════
   A BIG ICON, then two tight columns of facts, then the actions. The card carries NO Title, so the fixed-width
   left block is removed entirely rather than left sitting there empty and reserving its 150px.

   THE AGREEMENT CARD SHARES THIS BLOCK RATHER THAN COPYING IT — same shape, same problems, and a second copy
   would be a second place to forget the min-width:0 that makes truncation work. */
.zt-facility-card .ztdc-main,
.zt-agreement-card .ztdc-main { display: none; }
/* ⚠️ THE DISC TIGHTENS ON THE BIG ICONS, THE GLYPH DOES NOT CHANGE (Vince, 2026-08-05: 10% smaller circle,
   same icon). font-size drives the GLYPH; width/height drive the DISC — so overriding only the box shrinks the
   circle and leaves the icon alone. The default 2em is right at 1.4rem but reads loose once scaled to 2.1rem. */
.zt-facility-card .ztdc-icon,
.zt-agreement-card .ztdc-icon { font-size: 2.1rem; width: 1.8em; height: 1.8em; }

/* THE DETAILS AREA IS THE COLUMNS' CONTAINER, not a grid of its own — see the markup note: the number of
   spaces varies per facility, so an auto-flowing grid would move facts between sides from card to card. */
.zt-facility-card .ztdc-details,
.zt-agreement-card .ztdc-details { display: block; }

/* ⚠️ THE RIGHT PADDING HAS TO CLEAR THE BUTTONS. .ztdc-actions is position:ABSOLUTE at right:14px, and the base
   card reserves only 52px — enough for ONE icon button. These cards have TWO (edit + archive/restore/delete), so
   the details ran straight under them and never truncated, because nothing was constraining the width
   (Vince, 2026-08-05). 96px clears both plus the gap, and it is what makes the ellipsis actually engage.

   ⚠️ .ztdc.zt-facility-card — TWO CLASSES ON PURPOSE. The base rule is `.ztdc { padding: 12px 52px … }`, a
   shorthand at equal specificity that is declared LATER in this file, so a single-class override silently lost
   and the padding stayed 52px (measured). Two classes beat it regardless of order. */
.ztdc.zt-facility-card,
.ztdc.zt-agreement-card { padding-right: 96px; }

/* ── PHONES: THESE TWO CARDS DIVERGE, AND THE REASON IS HOW MANY FACTS EACH CARRIES ────────────────────────
   The shared .ztdc mobile rule sends the details to their own full-width row (flex-basis: 100%), which left the
   big leading icon sitting alone on a line above them. Right for a card with a lot to say; wrong for one with
   three short lines. So:

   AGREEMENT — KEEPS THE ICON, FACTS BESIDE IT (Vince, 2026-08-07: "the name, the price and the frequency all
   need to be to the right of that icon even on a small screen"). Three short values fit next to a 2.1rem disc
   at any phone width, and the icon is what tells you at a glance which kind of row this is.

   FACILITY — DROPS THE ICON ENTIRELY ("that big icon by itself doesn't look great, let's just get rid of the
   icon on tiny screens and leave it the way it is on big screens"). This card carries six facts plus a "No
   spaces!" warning, so they genuinely need the full width — and once the icon is on a row of its own it has
   stopped being the anchor your eye starts from and become a lone circle over a stack of text. Exactly the
   call the settings rows already made at this width.

   Both keep their DESKTOP shape untouched, which is the other half of what was asked. */
@media (max-width: 599px) {
	.zt-agreement-card .ztdc-details { flex-basis: auto; }
	.zt-facility-card .ztdc-icon { display: none; }
}

/* TWO COLUMNS, AND THE FIRST SIZES TO ITS CONTENT.
   repeat(2, 1fr) gave each column HALF the card, so a short left column left a ~250px gulf before the right one
   started — the "way too much space" that survived tightening the gap, because the gap was never the problem.
   max-content makes column one exactly as wide as its longest line; minmax(0, …) keeps it able to SHRINK so a
   very long name still ellipses instead of shoving column two off the card. */
.zt-facility-cols {
	display: grid;
	grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
	gap: var(--zt-item-gap) 1.25rem;
}
.zt-facility-col { min-width: 0; }

/* ADDRESS LINE 2 — no icon of its own, indented to sit under line one's text rather than under its globe. */
.zt-facility-addr2 .zt-content-item-icon { visibility: hidden; }

/* TRUNCATION — .zt-content-item-value already carries nowrap/ellipsis, but it is a FLEX CHILD and a flex child
   will not shrink below its content without min-width:0. That is why the address ran under the buttons instead
   of ellipsing. Scoped to this card rather than fixed globally, because relaxing it site-wide would change
   every content item's wrapping at once. */
.zt-facility-card .zt-content-item,
.zt-agreement-card .zt-content-item { min-width: 0; }
.zt-facility-card .zt-content-item-value,
.zt-agreement-card .zt-content-item-value { min-width: 0; }

/* ≤700px: one column. Two columns of truncated text on a phone is two columns of ellipses. */
@media (max-width: 599px) {
	.zt-facility-cols { grid-template-columns: minmax(0, 1fr); }
}

/* THE AGREEMENT CARD'S FACTS — ONE COLUMN, THREE ROWS: name, price, frequency (Vince, 2026-08-05).
   NO RULE IS NEEDED, and that is the note: `.zt-content-items` is ALREADY a vertical stack, so the card passes
   no layout class. A row override lived here briefly and was wrong twice over — it fought the base
   flex-direction and then, once forced to row, was not what was asked for. Same on every screen size. */

/* ═══ REQUIRED DOCUMENTS ════════════════════════════════════════════════════════════════════════════════
   ONE SECTION PER DOCUMENT, and inside it THREE ZtDetailCard ROWS — Details, Published Document, Draft
   Document — the EXACT control the user home's "My Programs" memberships use (Vince, 2026-08-06: "I really
   just much prefer the way we have the program membership set up ... that little rectangle with a nice
   outline"). Two earlier shapes were tried and rejected: nested action cards (boxes inside boxes) and
   full-bleed section bands. This is the third and it invents nothing — .ztdc already draws the outlined row,
   the circled leading icon, the two-column fact grid and the right-hand action area.

   The BODY is a flex column on the shared stack gap, exactly like .zt-programs-section, so the rows sit on
   the same rhythm as the program rows and their own margins can't double up on it. */
.zt-document-card > .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-document-card > .zt-action-card-body > .ztdc { margin-top: 0; margin-bottom: 0; }

/* ⚠️ UP TO FIVE VERBS, NOT ONE EDIT BUTTON. .ztdc pins .ztdc-actions absolutely to the right and reserves
   52px of padding for it — right for a single Edit, hopeless for Edit / Preview / Download / Restore / Delete,
   which would run straight off the card edge. So on a document row the actions come back INTO the flex flow as
   their own wrapping group: still on the right, still after the facts, but able to wrap. */
.zt-document-row { padding-right: 16px; }
.zt-document-row .ztdc-actions {
	position: static; transform: none;
	flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 0.4rem; max-width: 100%;
}

/* ONE COLUMN ON EVERY ROW, INCLUDING THE COUNTS (Vince, 2026-08-06: "instead of having two columns there,
   let's just have one column ... let's just make that go one taller"). A document's facts are full-width
   phrases — a title, a timestamp, "Required Signatures: 625" — and the 2-col grid chopped them into stubby
   half-width cells with a ragged gutter down the middle. */
.zt-document-row .ztdc-details { grid-template-columns: minmax(0, 1fr); }

/* ⚠️ THE THREE COLUMNS MUST LINE UP ROW TO ROW. .ztdc sizes its title block to its CONTENT (flex: 0 0 auto),
   so "Details" and "Published Doc" produced different widths and every row's facts started at a different x —
   the mis-alignment Vince spotted. A FIXED basis makes the fact column start at the same place on all three
   rows; the actions stay right-aligned against the card edge, so that column lines up too. */
.zt-document-row .ztdc-main { flex: 0 0 190px; }

/* THE VERSION ROWS READ LIGHTER THAN THE COUNTS ROW. Their facts are a document TITLE and a timestamp, which
   at the shared 700 weight shouted over everything else on the page — a label/value pair carries a light label
   to balance its bold value, and these have no label at all. Same information, quieter voice. */
.zt-document-version .ztdc-details { font-size: 0.92rem; }
.zt-document-version .ztdc-details .zt-content-item-value { font-weight: 600; }

/* ⚠️ NO WHITE BODY BAND ON A PHONE — ONE PLAIN TINTED BLOCK (Vince, 2026-08-07: "get rid of the white body in
   the middle on these mobile devices"). This row used to take the banded header / WHITE BODY / footer treatment
   at this width, and it is the SECOND control to lose it for the same reason the CA search result did ("we can
   do away with that white bodies section"): the middle band is three short fact lines, so a solid white slab
   full-bleeding across a card that is itself a near-white tint drew a box around nothing. The bands were never
   the point — STACKING was, and stacking is what wrapping already does.

   SO THE ROW GOES BACK TO BEING A PADDED CARD: its own padding, its own gap, and each block simply sits on the
   card's background. Same three blocks in the same order, one fewer surface.

   ⚠️ ROW-GAP DOES THE SPACING NOW, NOT THREE SETS OF BAND PADDING. Leaving the per-band padding behind would
   have doubled at every seam (12px of header margin meeting 12px of body padding = a 24px hole), which is the
   airy, disconnected look this is meant to fix. One gap, declared once.

   THE THREE BANDED CARDS STAY IDENTICAL ON A PHONE — this block, .zt-program-card and .zt-family-card all lost
   the white body together and share these numbers, so a document row, a membership row and a family row still
   read as the same object at the same width. */
@media (max-width: 599px) {
	/* THE SECTION'S OWN PADDING COUNTS TOO — the same trim the CA results card takes. The flush card body spends
	   1.15rem on each side, and on a 375px phone that is ~37px the document rows do not get, which is most of a
	   button. The rows are the content here; the frame around them does not need to be that generous. */
	.zt-document-card > .zt-action-card-body { padding: 0.7rem 0.6rem; }
	/* ⚠️ THE FILL IS NOT RESTATED HERE — same reason as the program card above. This used to hard-code the blue
	   wash as the phone default and then patch green/gold back on top, which is a second copy of the tone table
	   that only covered the two tones the section happened to use at the time. The shared table covers all six at
	   every width; only the padding/stacking belongs in a width query. */
	.zt-document-row { padding: 12px 16px; row-gap: 10px; column-gap: 11px; }
	.zt-document-row .ztdc-icon { margin: 0; align-self: center; }
	.zt-document-row .ztdc-main { flex: 1 1 auto; min-width: 0; margin: 0; align-self: center; }
	/* THE FACTS: full width under the title, on the card's own background — no band, no dividers, no padding. */
	.zt-document-row .ztdc-details { flex: 1 1 100%; margin: 0; padding: 0; background: none; border: none; }
	/* THE BUTTONS keep the full width and wrap there rather than squeezing in beside the facts — that part of the
	   banded treatment was right and is all that survives it. */
	.zt-document-row .ztdc-actions { flex: 1 1 100%; justify-content: flex-start; margin: 0; padding: 0; }
}

/* THE SIGNATURE / MISSING-SIGNATURE PAGES still stack real nested cards (one card per signee), so they keep
   the shared rhythm: each owns the gap ABOVE it, and the first drops it so it sits tight under the header. */
.zt-document-side { margin-top: var(--zt-stack-gap, 1rem); }
.zt-document-side:first-child { margin-top: 0; }

/* USER-HOME DOCUMENTS — one flat, COMPACT list of signed-document rows, THREE COLUMNS each (Vince,
   2026-08-11): the connected account's AVATAR | ONE quiet column of facts (name, signed, document date) | the
   small icon buttons (Preview · Download · History · version warning). The ZtDetailCard TITLE BLOCK IS UNUSED
   — the name is a content item at the same quiet size as the dates, which is what tames the "too big, too
   brash" first cut. The buttons sit STATIC and vertically centred, not in the absolute top-right edit spot.
   ⚠️ These rules sit AFTER the ≤599px .zt-document-row phone treatment above, so the equal-specificity pairs
   here win at every width — the media block below restores the buttons-last phone stacking on purpose. */
/* ⚠️ A DELIBERATE LOCAL GAP, NOT the shared --zt-stack-gap: Vince wants the document rows TIGHTER than the
   Family/Programs rhythm (2026-08-11 — and reverted a global tighten the same day: only the documents move).
   ⚠️ .ztdc CARRIES ITS OWN 0.5em VERTICAL MARGINS, which stack on top of the flex gap — the same trap the
   programs/settings/schedule sections each had to zero out. Without this line the real space between rows was
   ~21px no matter what the gap said. */
.zt-doc-list { display: flex; flex-direction: column; gap: 0.4rem; }
.zt-doc-list > .ztdc { margin-top: 0; margin-bottom: 0; }
.zt-document-row.zt-doc-row { padding: 8px 14px; }
.zt-doc-row .ztdc-main { display: none; }
.zt-doc-row .ztdc-details { grid-template-columns: minmax(0, 1fr); font-size: 0.85rem; gap: 0.15rem 1rem; }
.zt-doc-row .ztdc-details .zt-content-item-value { font-weight: 600; }
/* ALL THREE FACTS ARE ONE BLOCK IN THE MARKUP (see UserDocumentsSection) so the phone layout can put the whole
   column beside the avatar as a SINGLE flex item. It carries its own gap because inside a wrapper the items no
   longer sit on .ztdc-details' grid gap. min-width:0 at every level is what lets the values ellipsis instead of
   forcing the column wider than the card. */
.zt-doc-facts { display: grid; gap: 0.15rem; min-width: 0; }
.zt-doc-facts .zt-content-item { min-width: 0; }
/* ⚠️ WIDE, THE NAME GOES BACK ON TOP. It is LAST in the DOM because that is the collapsed reading order, and
   Vince asked for the wide row to be left exactly as it was — so order:-1 lifts it to the head of the quiet
   column here and the desktop card is unchanged. (order works in grid containers, which is what this is.) */
.zt-doc-row .zt-doc-name { order: -1; min-width: 0; }
.zt-doc-row .ztdc-actions { position: static; display: flex; align-items: center; gap: 0.3rem; margin-left: auto; align-self: center; }
/* PREVIEW/DOWNLOAD ride NATIVE FORMS (cookie-enter + file POST) — contents, so each form's button sits in the
   action row as if the form wrapper were not there. .zt-doc-dl is the same trick for the download form in the
   preview wave's FOOTER (the footer is a flex row of buttons). */
.zt-doc-row .ztdc-actions form { display: contents; }
.zt-doc-dl { display: contents; }
/* ── 📱 COLLAPSED: ONE COLUMN OF FACTS BESIDE THE PICTURE, BUTTONS UNDER ────────────────────────────────────
   Vince, 2026-08-18, third pass: "let's just put all three lines in one column to the right of the profile
   picture. And let's center the profile picture horizontally, make that profile picture a little bigger, the
   font a little smaller with truncating and ellipses."

   THE PROBLEM IT STARTED FROM: stacked, the 48px avatar sat alone on line one with nothing to its right, so
   every card opened with a band of empty space before there was anything to read.

   ⚠️ FLEX WITH A REAL WRAPPER — NOT 2-D GRID PLACEMENT, AND THAT IS THE FIX ITSELF. The first attempt dissolved
   .ztdc-details with display:contents and pinned each loose item to a grid cell. It shipped the OPPOSITE of what
   was asked: the NAME beside the avatar with the dates wrapping underneath. Placement rules are only as good as
   the cascade that reaches them, and this row is styled from four blocks at three specificities.

   So ALL THREE FACTS ARE NOW ONE ELEMENT in the markup (.zt-doc-facts). That turns the whole thing back into
   ordinary wrapping: two flex items on line one, the buttons forced to 100% under them. There is nothing here
   that can half-apply — if every rule below were dropped the row would fall back to the plain stack it was
   before, never to a scrambled one.

   ⚠️ AND ONE COLUMN IS WHY THE PICTURE FINALLY SITS RIGHT. With the dates and the name on two SEPARATE flex
   lines, `align-items: center` centred the avatar against the FIRST line only, so a 3-line card left it sitting
   high — the thing that still looked wrong after attempt two. One box, one line, centred against all of it.

   ⚠️ ONLY WHEN IT COLLAPSES. Wide, the row keeps its avatar | facts | buttons layout untouched — that is the one
   thing Vince asked for twice in the same sentence, and .zt-doc-name's order:-1 above is what preserves it. */
@media (max-width: 599px) {
	/* ⛔ TWO CLASSES, AND THAT IS NOT COSMETIC — IT IS WHY THE FIRST ATTEMPT SILENTLY DID NOTHING.
	   A bare `.zt-doc-row` here is specificity (0,1,0), and BOTH of these sit LATER in this file:
	       .ztdc { display: flex; … }                       (line ~4670, no media query)
	       @media (max-width:599px) { .ztdc { flex-wrap: wrap; row-gap: 12px; … } }
	   Equal specificity + later in source = they win, in a media query or not. The first version of this block
	   said `.zt-doc-row { display: grid }` and was overruled by that base `display: flex` — so the grid never
	   existed, while the (0,2,0) `display: contents` on .ztdc-details DID apply, dumping the three facts loose
	   into the old flex row. That is exactly the scrambled card Vince screenshotted.
	   ⚠️ ANY row-level property added here needs the doubled selector for the same reason. */
	.zt-document-row.zt-doc-row { flex-wrap: wrap; align-items: center; row-gap: 6px; column-gap: 11px; }
	/* DISSOLVE THE FACTS BOX so .zt-doc-facts becomes a flex item of the ROW itself — that is what puts the
	   whole three-line column beside the avatar rather than under it. */
	.zt-doc-row .ztdc-details { display: contents; }
	/* 🖼️ A BIGGER PICTURE (Vince, 2026-08-18). 48px read as a thumbnail beside three lines of text; at 64 it is
	   the academy's mark again, which is the point of it being there. It is the row's only other flex item, so
	   it IS its own column — every card's text starts at the same x — and the row's `align-items: center` now
	   centres it against the whole three-line block rather than against one line of it.
	   ⚠️ WIDTH AND HEIGHT ONLY. Do NOT give it a flex-basis to make a wider "column": basis sets the item's main
	   size, so the 64px circle would become a 72×64 ELLIPSE under .ztdc-avatar's border-radius:50%. */
	.zt-doc-row .ztdc-avatar { width: 64px; height: 64px; font-size: 26px; }
	/* THE COLUMN: everything that is left, and it must be allowed to shrink (basis 0 + min-width 0) or the long
	   membership-agreement names would push the card wider instead of ellipsising. */
	.zt-doc-row .zt-doc-facts { flex: 1 1 0; min-width: 0; }
	/* SMALLER TYPE HERE (Vince) — three lines beside a 64px picture want to read as one compact block, and the
	   smaller the value the later it has to truncate. .zt-content-item-value already carries the nowrap +
	   ellipsis; all it ever needed was a container that would let it shrink. Set on .ztdc-details because that
	   is where the 0.85rem comes from; the facts column inherits it. */
	.zt-doc-row .ztdc-details { font-size: 0.78rem; }
	/* COLLAPSED, THE NAME IS THE LAST LINE — order:0 undoes the wide-layout order:-1 above. */
	.zt-doc-row .zt-doc-name { order: 0; }
	/* THE BUTTONS TAKE THEIR OWN ROW UNDERNEATH, left-aligned (the global "card buttons go last on phones"). */
	.zt-doc-row .ztdc-actions { flex: 1 1 100%; justify-content: flex-start; margin: 0; flex-wrap: wrap; }
}

/* A SIGNATURE COUNT + ITS JUMP ARROW on one line. The count is the value; the arrow is a real icon BUTTON
   (it navigates), so it needs to sit tight against the number rather than inheriting the row's gap. */
.zt-doc-count { font-weight: 700; color: var(--zeal-medium-grey); }

/* THE DOWNLOAD BUTTON IS WRAPPED IN A <form> (a browser can only start a download from a navigation), and a
   form is a block — so without this it breaks the footer's button row onto its own line. */
.zt-document-download { display: contents; }

/* THE READER — the document body itself.
   ⚠️ THIS IS A COPY OF THE PDF RENDERER'S OWN STYLESHEET, NOT A NEW LOOK. ZealPdf.GetDocumentPdf injects
   exactly these rules before handing the HTML to wkhtmltopdf, so the preview and the downloaded file are the
   same document. Anything invented here would make the on-screen copy disagree with the one members sign.
   (Ported verbatim from that method, plus the responsive guards a screen needs and paper does not.)

   ⚠️ THE EDITOR'S SURFACE IS LISTED ON THE SAME RULES ON PURPOSE (SurfaceClass="zt-rte-document", added
   2026-08-06). WYSIWYG only means something if the W-Y-S is the same stylesheet as the W-Y-G: without this the
   editor showed the app's generic rich-text typography — 1em of margin above and below every paragraph — while
   the preview and the PDF render paragraphs with NO vertical margin at all. The author saw airy spacing that
   vanished the moment they saved (Vince, 2026-08-06: "it looks like there's extra spaces ... we've had this
   issue before"). ONE DEFINITION, THREE SURFACES — editor, preview, PDF — so it cannot drift again. */
.zt-document-body p,
.zt-rte-surface.zt-rte-document p {
	font-family: sans-serif, Arial, -apple-system, Roboto, 'Segoe UI';
	color: #555555;
	line-height: 1.2;
	margin-top: 0 !important;
	margin-bottom: 0 !important;
}
.zt-document-body ol,
.zt-document-body ul,
.zt-rte-surface.zt-rte-document ol,
.zt-rte-surface.zt-rte-document ul { font-family: inherit; font-size: inherit; color: inherit; }

/* THE SURFACE ITSELF takes the same family and colour, so text typed OUTSIDE a paragraph (the first line of a
   fresh document) still looks like the document rather than like the app. */
.zt-rte-surface.zt-rte-document { font-family: sans-serif; color: #555555; line-height: 1.2; }

/* ⚠️ AND THE LIST MARGINS GO BACK TO THE BROWSER DEFAULT. .zt-rte-surface ul/ol tighten lists to 0.5em for the
   app's own editors; neither the preview nor wkhtmltopdf applies that, so a bulleted clause sat tighter in the
   editor than in the signed document. Specificity (0,3,1 vs 0,1,1) settles it wherever these two rules land. */
.zt-rte-surface.zt-rte-document ul,
.zt-rte-surface.zt-rte-document ol { margin: 1em 0; }

/* SCREEN-ONLY GUARDS. The PDF has a fixed page width; a browser does not, so an oversized logo or a wide table
   would otherwise push the whole card sideways. */
.zt-document-body img { max-width: 100%; height: auto; }
.zt-document-body table { max-width: 100%; }
.zt-document-body { overflow-x: auto; }
.zt-doc-jump.ztbtn.ztbtn-icon {
	width: 1.5rem; height: 1.5rem; min-width: 0; min-height: 0;
	padding: 0; margin-left: 0.4rem; border-radius: 50%; font-size: 0.7rem;
	background: transparent; border-color: transparent; color: var(--zeal-green); box-shadow: none;
}
.zt-doc-jump.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-dark-green, #1d6f42); filter: none; }

/* ═══ SIGNATURES ════════════════════════════════════════════════════════════════════════════════════════
   THE LEGEND on the signed-documents page — the three words are coloured to match the cards below them, so
   the sentence teaches the colours rather than just naming them. */
.zt-legend-current { color: var(--zeal-green); }
.zt-legend-rejected { color: var(--zt-gold-accent); }
.zt-legend-old { color: var(--zeal-medium-grey); }

/* THE SIGNATURE CARD — same big-icon shape as the facility and agreement cards, so it needs the same
   right-padding to clear its action buttons and the same min-width:0 for truncation to engage. */
.zt-signature-card .ztdc-main { display: none; }
.ztdc.zt-signature-card { padding-right: 132px; }
.zt-signature-card .zt-content-item,
.zt-signature-card .zt-content-item-value { min-width: 0; }

/* THE SIGNATURE BLOCK in the signed reader — the image, the signee, then the attestation in fine print.
   Sizes ported from the legacy preview's inline <style>, which is what the PDF matches. */
.zt-signature-block { margin-top: 1rem; }
.zt-signature-image { width: 300px; max-width: 100%; height: 100px; object-fit: contain; }
.zt-signature-signee { font-weight: 700; font-size: 18px; color: var(--zeal-medium-grey); }
.zt-signature-info { font-weight: 700; font-size: 8px; color: var(--zeal-medium-grey); line-height: 1.4; margin-top: 0.5rem; }

/* THE MISSING LIST — a long column of names. Nothing to click, so nothing that looks clickable. */
.zt-missing-list { margin-top: var(--zt-stack-gap, 1rem); }

/* THE LETTER EDITOR — the intro note above the rich text, small like every other standalone note. */
.zt-letter-intro { margin: 0; line-height: 1.45; font-size: 0.85rem; }

/* THE SCRIPTING-TAG SHEET — buttons that wrap instead of forcing the modal wide. */
.zt-letter-tags { display: flex; flex-wrap: wrap; gap: 0.4rem; }

/* THE TONE BANDS — happy / warn / error / neutral, straight off the shared tokens. The HEADER and the FOOTER are
   listed TOGETHER for each tone: a card's two bands are one state, and styling them in separate rules is how a
   gold header ended up over a generic grey footer. */
.zt-happy.zt-action-card-flush .zt-action-card-header,
.zt-happy.zt-action-card-flush .zt-action-card-footer { background: var(--zt-happy-bg); border-color: var(--zt-happy-bd); }
.zt-warn.zt-action-card-flush .zt-action-card-header,
.zt-warn.zt-action-card-flush .zt-action-card-footer  { background: var(--zt-warn-bg);  border-color: var(--zt-warn-bd); }
.zt-error.zt-action-card-flush .zt-action-card-header,
.zt-error.zt-action-card-flush .zt-action-card-footer { background: var(--zt-error-bg); border-color: var(--zt-error-bd); }
.zt-neutral.zt-action-card-flush .zt-action-card-header,
.zt-neutral.zt-action-card-flush .zt-action-card-footer { background: var(--zt-neutral-bg); border-color: var(--zt-neutral-bd); }
/* The footer is a button tray: wrap on narrow screens rather than overflow.
   ⚠️ zt-plain IS IN THIS LIST. It was the one tone left out, so a grey-toned card's footer buttons had NO gap
   rule at all and fell back to whatever whitespace the HTML happened to leave between them — which is exactly
   how the member search's Search / Reset / New ended up unevenly spaced (Vince, 2026-08-07: "that new button
   isn't spaced correctly"). A tone is a colour, never a layout difference. */
.zt-happy.zt-action-card-flush .zt-action-card-footer,
.zt-warn.zt-action-card-flush .zt-action-card-footer,
.zt-error.zt-action-card-flush .zt-action-card-footer,
.zt-plain.zt-action-card-flush .zt-action-card-footer,
.zt-neutral.zt-action-card-flush .zt-action-card-footer { display: flex; flex-wrap: wrap; gap: 0.4rem; }
/* THE FIVE TONE CLASSES — the whole vocabulary. Put one of these on a card and it takes that tone's fill and
   border everywhere it appears; nothing else may introduce a tint. Live reference at /testform. */
.zt-happy   { border: 1px solid var(--zt-happy-bd); }
.zt-warn    { border: 1px solid var(--zt-warn-bd); }
.zt-error   { border: 1px solid var(--zt-error-bd); }
.zt-neutral { border: 1px solid var(--zt-neutral-bd); }
.zt-plain   { border: 1px solid #e4e4e4; }
.zt-plain.zt-action-card-flush .zt-action-card-header,
.zt-plain.zt-action-card-flush .zt-action-card-footer { background: var(--zt-plain-bg); border-color: #e4e4e4; }

/* ══ 🃏 `zt-row-card` — AN ACTION CARD THAT IS AN *ITEM IN A LIST*, NOT A SECTION FRAME ═══════════════════
   Vince, 2026-08-16, looking at his own Family list after the row-card sweep: "now that we have everything looking
   so consistent, the family controls kinda stick out. It would be great if we could put these family members as a
   blue neutral … but that accent on the left side with a very thin border around the entire thing, the header and
   the body with the accent, same thing on the left across the header and body, that would look super sexy."

   ⚠️ THE DISTINCTION THIS CLASS EXISTS TO KEEP IS THE ONE THE WHOLE TONE SYSTEM RESTS ON: **a SECTION is a FRAME
   and wears a hairline; a ROW is an OBJECT and wears its state.** A family member card is built out of
   ZtActionCard — the same control a section is built from — so without an opt-in there is no way to give the
   member the accent without also striping every section on the page. Put this on a card that is an ENTRY in a
   list; never on a card that CONTAINS a list.

   WHY THE TAB SPANS BOTH BANDS FOR FREE: the header/footer bands are INSIDE the card, so a border on the card
   runs the full height past all of them. Nothing per-band is needed, and nothing can fall out of step.

   ⚠️ IT ALSO REPLACES `.zt-action-card`'s 6px TRANSPARENT TOP with a 1px accent one — that reserve exists so an
   accented card lines up with a plain one in the same stack, and inside a row-card list every card is a row card,
   so they still line up with each other. Do not mix a row card and a plain card in one list. */
/* ⚠️⚠️ EACH TONE DECLARES ITS OWN 4px LEFT EDGE, AND SPLITTING THAT OUT IS A BUG — IT ALREADY SHIPPED AS ONE.
   The first cut of this block put the width in a separate `.zt-row-card { border-left-width: 4px }` rule with a
   comment claiming "(0,1,0) is enough: nothing else sets a left width on these." IT WAS NOT ENOUGH: the tone
   rules right here are (0,2,0) and their `border:` SHORTHAND resets all four widths to 1px, so the higher
   specificity beat the tab and every family card shipped with a border and no accent (Vince: "the family controls
   still don't have the accent. The border is nice, but we need the accent as well").

   SO THE WIDTH LIVES INSIDE THE TONE, in the same rule as the colour it belongs to. `border-width` right after
   the shorthand cannot be out-specified by anything that is not also out-specifying the colour — the two can no
   longer disagree, which is the actual fix rather than a specificity arms race. */
.zt-row-card.zt-happy   { border: solid var(--zeal-green);     border-width: 1px 1px 1px 4px; --zt-row-glow: var(--zt-happy-glow); }
.zt-row-card.zt-warn    { border: solid var(--zt-gold-accent); border-width: 1px 1px 1px 4px; --zt-row-glow: var(--zt-warn-glow); }
.zt-row-card.zt-neutral { border: solid var(--zeal-blue);      border-width: 1px 1px 1px 4px; --zt-row-glow: var(--zt-neutral-glow); }
.zt-row-card.zt-error   { border: solid var(--zeal-red);       border-width: 1px 1px 1px 4px; --zt-row-glow: var(--zt-error-glow); }
.zt-row-card.zt-plain   { border: solid var(--zeal-silver);    border-width: 1px 1px 1px 4px; --zt-row-glow: var(--zt-plain-glow); }
.zt-row-card { transition: box-shadow 0.12s ease; }

/* THE SHARED HOVER LIFT. ⚠️ (hover: hover) — charter rule 7a.
   ⚠️ IT MUST RESTATE THE CARD'S OWN DROP SHADOW. `.zt-action-card` carries `box-shadow: 0 1px 5px …`, and
   box-shadow does not merge — naming only the glow here would DELETE the shadow on hover and the card would
   visibly flatten as the cursor arrived, which is the opposite of a lift. */
@media (hover: hover) {
	.zt-row-card:hover { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 2px 8px var(--zt-row-glow); }
}
/* ⚠️ THE BANDS CARRY THE TONE. THE BODY STAYS WHITE. (Vince, 2026-08-04)
   These five briefly set the tone on the WHOLE card, which tinted the body as well — the user home's connected
   account cards went pale green behind their program rows. A tone is a LABEL on a card, not a wash over its
   contents: content reads on white, and the header/footer bands say what state it is in. The header/footer
   rules above are the whole implementation; the card keeps .zt-action-card's white background. */

/* THE TITLE ICON TAKES THE DARK VERSION OF THE TONE. The background is a wash you should barely notice; the
   ICON is what names the state at a glance, so it carries the strong colour instead. Covers all three title
   icons — the flush coloured band, a plain action-card title, and a ZtPageSection title — so a tone looks the
   same whichever container it lands in. */
.zt-happy .zt-action-card-band-title-icon,
.zt-happy .zt-action-card-title-icon,
.zt-happy .zt-page-section-title-icon   { color: var(--zeal-dark-green); }
.zt-warn .zt-action-card-band-title-icon,
.zt-warn .zt-action-card-title-icon,
.zt-warn .zt-page-section-title-icon    { color: var(--zeal-dark-gold); }
.zt-error .zt-action-card-band-title-icon,
.zt-error .zt-action-card-title-icon,
.zt-error .zt-page-section-title-icon   { color: var(--zeal-dark-red); }
.zt-neutral .zt-action-card-band-title-icon,
.zt-neutral .zt-action-card-title-icon,
.zt-neutral .zt-page-section-title-icon { color: var(--zeal-dark-blue); }
.zt-plain .zt-action-card-band-title-icon,
.zt-plain .zt-action-card-title-icon,
.zt-plain .zt-page-section-title-icon   { color: var(--zeal-dark-grey); }

/* The full-screen ATTENDANCE panel is not a flush card, so it tints its own header band with the SAME happy/warn
   colours (green when the rank has attendance, yellow when it has none). Belt colour still drives the title + entries. */
/* CONNECTED-ACCOUNT (programs) sections — the CA identity now lives in the grey FilledHeaderFooter header band
   (logo + name + role left, nav buttons right); the programs stack in the body. */
.zt-programs-section { margin-bottom: 0.75em; }
.zt-programs-section .zt-programs-account { margin-bottom: 0; }

/* THE PROGRAM STACK, ON THE SHARED RHYTHM.

   WHAT WAS WRONG: the program cards are plain block children of the card body, and .ztdc carries its own
   margin-top/-bottom: 0.5em. So the space ABOVE the first card was body padding + that margin (~21.6px), the
   space BETWEEN two cards was the collapsed margin alone (~8px), and the space BELOW the last was body padding
   only (~13.6px) because .zt-action-card-body > :last-child zeroes the final margin. Three different gaps —
   which is exactly the uneven top-vs-bottom Vince spotted.

   THE FIX: make the body a flex column so the margins stop collapsing and a single gap governs, then drop the
   cards' own vertical margins. Top / between / bottom all become --zt-stack-gap. */
.zt-programs-section > .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-programs-section > .zt-action-card-body > .ztdc { margin-top: 0; margin-bottom: 0; }
/* reusable EMPTY-STATE notice (ZtEmptyState) — a BIG, vertically-centered icon + title + message, shared by every
   "no data yet" section so they read identically. */
.zt-empty { display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.2rem; background: #f4f5f5; border-left: 4px solid var(--zeal-grey); border-radius: 6px; }
/* AN EMPTY STATE ALONE IN A SECTION NEEDS THE SAME AIR ABOVE IT AS BELOW IT (Vince, 2026-08-15, on the user
   home's "No programs yet"). The two gaps are built from different values, which is why they didn't match: BELOW
   is the section's own bottom padding (0.75em), ABOVE is only the title's margin (0.2em) — so the notice sat
   tight under "My Programs" and floated well clear of the card's bottom edge. 0.55em closes exactly that
   difference: 0.2 + 0.55 = 0.75, equal on both sides, measured on what a reader actually sees.

   ⚠️ DELIBERATELY NOT A CHANGE TO `.zt-page-section-title { margin-bottom }`. That 0.2em is a GLOBAL decision
   Vince made on 2026-08-04 — a title should bind to the text it introduces — and loosening it to fix one
   section's empty state would push every heading on the site apart. Scoped to the empty state, and only when it
   is the section's whole body, so a section that has content followed by an empty block is untouched. */
.zt-page-section-body > .zt-empty:only-child { margin-top: 0.55em; }
.zt-empty-icon { flex: 0 0 auto; display: inline-flex; align-items: center; font-size: 1.9rem; color: var(--zeal-grey); }
/* ⚠️ `flex: 1 1 0` IS THE SMALL-SCREEN ANSWER, not just a filler. It makes the TEXT the thing that gives way, so
   an <Actions> button stays on the row and the message wraps beneath itself instead of shoving the button off
   the edge of a 360px phone. min-width:0 lets it shrink past its longest word. */
.zt-empty-text { flex: 1 1 0; min-width: 0; }
/* THE WAY OUT — hard right, vertically centred (the parent's align-items does it), the notice's own 1.2rem of
   right padding as the gutter. margin-left:auto rather than justify-content, so it is still right-aligned when
   the caller supplies no actions at all and this element does not exist. */
.zt-empty-actions { flex: 0 0 auto; margin-left: auto; display: flex; align-items: center; gap: 0.4rem; padding-left: 1rem; }
.zt-empty-title { font-weight: 700; color: var(--zeal-dark-grey); margin-bottom: 0.1rem; }
.zt-empty-message { color: var(--zeal-medium-grey); font-size: 0.92rem; }
/* a plain WRAPPING confirmation line in a modal body (NOT a ZtContentItem — that truncates with an ellipsis). */
.zt-modal-confirm { display: flex; align-items: flex-start; gap: 0.6rem; line-height: 1.5; color: var(--zeal-dark-grey); }
.zt-modal-confirm-icon { color: var(--zeal-tiffany); flex: 0 0 auto; margin-top: 0.15rem; }
/* card-buttons HELP key — each row: the real button sample + its name + what it does. */
.zt-cc-help { display: flex; flex-direction: column; gap: 0.45rem; }
.zt-cc-help-row { display: flex; align-items: center; gap: 0.75rem; }
.zt-cc-help-row .ztbtn { flex: 0 0 auto; }
.zt-cc-help-text { min-width: 0; color: var(--zeal-dark-grey); line-height: 1.4; }
.zt-cc-help-name { font-weight: 700; margin-right: 0.25rem; }
/* nickname editor hint under the input. */
.zt-cc-nickname-hint { margin-top: 0.5rem; font-size: 0.82rem; color: var(--zeal-medium-grey); }
/* generic modal FORM — symmetric spacing: the input's own asymmetric top/bottom margin is neutralized so the
   modal body padding gives EQUAL space above/below the field. */
.zt-modal-form .zt-input-container { margin: 0; }
/* CHARGE control — its own panel: identity header over the form; 1:1 confirm. EQUAL space above/below the title,
   and ONE consistent gap between the fields and down to the footer. */
.zt-charge-card .zt-action-card-body { padding: 1rem 1.15rem; }
.zt-charge-title { display: flex; align-items: center; gap: 0.5rem; font-size: 1.15rem; font-weight: 700; color: #333; margin: 0 0 1rem 0; }
.zt-charge-title .zt-fa { color: var(--zeal-tiffany); }
.zt-charge-head { margin-bottom: 0; }
/* NO form-spacing override — the charge form uses the GLOBAL default .zt-input-container rhythm (same as every
   other form / the settings page). Don't hack per-form spacing. */
/* more breathing room above/below the Cancel/Charge footer buttons. */
.zt-charge-card .zt-action-card-footer { padding-top: 1.3rem; padding-bottom: 1.3rem; }
/* space above the declined/error alert. */
.zt-charge-error { margin-top: 0.85em; }
/* tighten the confirmation's stacked lines (via the ZtContentItems' own gap knob). */
.zt-charge-confirm { --zt-item-gap: 0px; }
/* ONE GAP GOVERNS THE BLOCK, inside a wave editor. Its children (lead paragraph, content items, a button) each
   brought their own margin, so the stack spaced itself differently depending on which parts were present — a
   wide hole under the paragraph, then the button jammed against the list. Same fix as the sections: one flex
   gap, children carry none. The ITEM rows stay tight (--zt-item-gap: 0) because the list is one child. */
.zt-wave-body .zt-charge-confirm { display: flex; flex-direction: column; gap: var(--zt-wave-gap); }
.zt-wave-body .zt-charge-confirm > * { margin: 0; }
/* A BUTTON IS NOT A BLOCK. A flex column stretches its children by default, so a lone button in one of these
   stacks ran the full width of the panel — a regression from turning this block into a flex column. Sizing it to
   its own content puts it back where it belongs; everything else still stretches, which is what text wants. */
.zt-wave-body .zt-charge-confirm > .ztbtn { align-self: flex-start; }
/* ALERTS KEEP NO MARGIN OF THEIR OWN IN HERE. .zt-alert carries margin-bottom for normal page flow, which landed
   ON TOP of the body's flex gap — so the space UNDER an alert was visibly larger than the space above it. The
   surrounding gap is the single source of rhythm; the alert just sits in it. */
.zt-wave-body .zt-alert { margin: 0; }
.zt-charge-confirm-heading { color: var(--zeal-red); font-weight: 800; letter-spacing: 0.02em; margin-bottom: 0.4rem; }
.zt-charge-confirm-lead { color: var(--zeal-dark-grey); line-height: 1.5; margin-bottom: 0.85rem; }
/* ══ 💳 THE CHARGE ROW (ZtChargeRow) — the COMPACT one-per-charge row that replaced the collapsible
   ZtChargeCard. ztdc family (tone, avatar, fact styling) with its OWN three wrappers — head / body / foot —
   because the PHONE layout is BANDED (Vince, 2026-08-20: "on smaller screens there's a header and a footer to
   this control"): header band = logo · status · amount · payment method, plain body = the remaining facts,
   footer band = the buttons. On roomy screens the wrappers dissolve and the card reads like every other ztdc
   row: disc left, fact columns, actions pinned right. ═══════════════════════════════════════════════════════ */
/* ONE CELL PER GROUP — stacks of facts that move as units, exactly like .zt-prog-rank-cell: a label can never
   be split from the facts under it. Compact type (the document rows' size), values weighted for scanning. */
.zt-charge-cell { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; font-size: 0.85rem; }
.zt-charge-row .zt-content-item-value { font-weight: 600; }
/* THE BAND TINT — a translucent darken over the card's tone fill, so ONE pair of rules serves all five tones
   instead of five hand-matched band colours. LIGHTENED from 5%/8% (Vince, 2026-08-20: "can we use a lighter
   gray… I like the way this looks") — just enough darken to read as a band, no more. */
.zt-charge-row { --zt-charge-band: rgba(0, 0, 0, 0.03); --zt-charge-band-bd: rgba(0, 0, 0, 0.06); }

/* ── ROOMY SCREENS (Medium+): the standard one-row ztdc layout. Head and foot DISSOLVE (display:contents) so
   the avatar, the lead cell and the pinned actions sit directly on the card's flex row; the body is a grid
   beside them. Lead flex 1 : body flex 2 (two equal columns) ≈ three equal fact columns on Expanded. */
@media (min-width: 600px) {
	.zt-charge-head, .zt-charge-foot { display: contents; }
	.zt-charge-cell-lead { flex: 1 1 0; }
	.zt-charge-body {
		flex: 2 1 0; min-width: 0;
		display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--zt-item-gap) 1.5rem;
	}
}
/* MEDIUM (foldables unfolded): two fact columns, not three — the body's pair stacks into one column. */
@media (min-width: 600px) and (max-width: 839px) {
	.zt-charge-body { grid-template-columns: minmax(0, 1fr); }
}

/* ── COMPACT (phones): THE BANDED CARD. padding:0 + overflow:hidden so the bands reach the rounded corners;
   the 4px tone tab still draws (it is a border, not a background). This card is in the :not() list of the
   global ≤599px padding rule because it manages its own. */
@media (max-width: 599px) {
	.ztdc.zt-charge-row { display: block; padding: 0; overflow: hidden; }
	.zt-charge-row .zt-charge-head {
		display: flex; align-items: center; gap: 12px; padding: 10px 14px;
		background: var(--zt-charge-band); border-bottom: 1px solid var(--zt-charge-band-bd);
	}
	.zt-charge-row .zt-charge-head .ztdc-avatar { margin: 0; }
	.zt-charge-row .zt-charge-cell-lead { flex: 1 1 auto; }
	.zt-charge-row .zt-charge-body { display: flex; flex-direction: column; gap: 0.3rem; padding: 12px 14px; }
	.zt-charge-row .zt-charge-foot {
		padding: 10px 14px;
		background: var(--zt-charge-band); border-top: 1px solid var(--zt-charge-band-bd);
	}
	/* the actions live IN the footer band, in flow, left-aligned (card buttons go LAST on phones). */
	.zt-charge-row .ztdc-actions { position: static; transform: none; margin: 0; justify-content: flex-start; }
}

/* A WON DISPUTE READS GREEN (Vince, 2026-08-20) — the one good line a disputed charge can show, so it must not
   wear the section's danger red. Rides on the server-stamped DisputeWon flag via this class, never on a status
   string parsed in the UI. */
.zt-dispute-won .zt-content-item-icon,
.zt-dispute-won .zt-content-item-value { color: var(--zeal-green); }
.zt-dispute-won .zt-content-item-value { font-weight: 700; }

/* ── THE CHARGE STATISTICS EDITOR — pie LEFT, per-status numbers RIGHT (Vince, 2026-08-21: "move the chart
   over to the left … on the right … numbers and amounts next to them"). The breakdown IS the legend: swatch ·
   status · count · dollars per row, with the total under a rule. Stacks to one column on a phone. ═══════════ */
.zt-charge-stats { display: flex; align-items: center; gap: 1.5rem; }
.zt-charge-stats-chart { flex: 0 1 46%; min-width: 0; }
/* cap the square pie so a wide panel doesn't inflate it to a dinner plate; it centers in its half. */
.zt-charge-stats-chart .zt-chart-svg { max-width: 320px; margin-inline: auto; }
.zt-charge-stats-facts { flex: 1 1 54%; min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.zt-charge-stats-row {
	display: grid; grid-template-columns: auto minmax(0, 1fr) auto minmax(6em, auto);
	align-items: center; gap: 0.6rem; font-size: 0.92rem;
}
/* the slice's colour chip — same shape as the chart legend swatch; colour arrives as a custom prop (the one
   sanctioned dynamic-style form at a call site). */
.zt-charge-stats-swatch { width: 11px; height: 11px; border-radius: 3px; background: var(--zt-swatch, #8a8a8a); }
.zt-charge-stats-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--zeal-dark-grey); font-weight: 600; }
/* tabular numerals so the counts and dollars line up down the column. */
.zt-charge-stats-count, .zt-charge-stats-amount { text-align: right; font-weight: 700; color: var(--zeal-dark-grey); font-variant-numeric: tabular-nums; }
.zt-charge-stats-total { border-top: 1px solid #e0e0e0; margin-top: 0.3rem; padding-top: 0.55rem; }
.zt-charge-stats-total-icon { color: var(--zeal-tiffany); line-height: 1; }
@media (max-width: 599px) {
	.zt-charge-stats { flex-direction: column; align-items: stretch; }
	.zt-charge-stats-chart { flex: 1 1 auto; }
}
.zt-cc-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.1rem; }
/* ⭐ THE CONFIRMED CARD ON "Confirm Your Payment" — it needs AIR above it (Vince, 2026-08-21: "let's put a
   little bit more vertical space between that and its paragraph above"). The card there is not one of a list to
   choose from any more; it is the answer to the sentence above it, and sitting on the wave's standard gap made
   it read as another paragraph. Separate class so the PICKER list, where the tight grid is right, is untouched. */
.zt-cc-list-confirm { margin-top: 0.9rem; }
@media (max-width: 599px) { .zt-cc-list { grid-template-columns: 1fr; } }
.zt-cc { display: flex; flex-direction: column; gap: 0.6rem; max-width: 599px; }
/* ONE consistent HOUSE colour for every card — a refined slate (the grey liked); the brand is conveyed by its
   LOGO, not the card colour. Real card aspect ratio (≈1.586). The DEFAULT card gets a tiffany ring. */
.zt-cc-face { position: relative; aspect-ratio: 1.586 / 1; border-radius: 14px; padding: 1.05rem 1.2rem; color: #fff;
	display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28);
	background: linear-gradient(135deg, #46536a 0%, #232a38 100%); }
/* DEFAULT card — a LIGHT TIFFANY face with DARK text (so it clearly reads as the default); it's also ordered
   FIRST in the list (server-side). Its logo/badges flip to dark-on-light for contrast. */
.zt-cc.is-default .zt-cc-face { background: linear-gradient(135deg, #d9f4f1 0%, #b4e7e2 100%); color: var(--zeal-dark-grey); box-shadow: 0 0 0 2px var(--zeal-tiffany), 0 8px 22px rgba(0, 0, 0, 0.2); }
/* ⭐ PICKABLE card (OnPick set) — the whole face is the tap target; a gentle lift says "clickable" without a
   button. is-selected = the CHOSEN ring, green (the go colour), and it WINS over the default's tiffany ring so
   the picked card always reads as picked. */
.zt-cc.zt-cc-pick { cursor: pointer; }
.zt-cc.zt-cc-pick .zt-cc-face { transition: transform 0.12s ease, box-shadow 0.12s ease; }
.zt-cc.zt-cc-pick:hover .zt-cc-face { transform: translateY(-2px); box-shadow: 0 12px 26px rgba(0, 0, 0, 0.32); }
.zt-cc.is-selected .zt-cc-face,
.zt-cc.is-default.is-selected .zt-cc-face { box-shadow: 0 0 0 3px var(--zeal-green), 0 8px 22px rgba(0, 0, 0, 0.25); }
.zt-cc.is-default .zt-cc-brand { color: var(--zeal-dark-grey); }
.zt-cc.is-default .zt-cc-funding { background: rgba(0, 0, 0, 0.1); color: var(--zeal-dark-grey); }
.zt-cc.is-default .zt-cc-default { background: var(--zeal-dark-grey); color: #fff; }
/* NEEDS-ATTENTION card — an AMBER face for an account that cannot receive money at all. Same construction as
   is-default (light face, dark text, coloured ring) so the two read as one family; it simply wins on colour,
   because "this account is broken" outranks "this is the default one". Amber, not the near-white --zt-warn-bg:
   that token is a full-panel wash and disappears at card size. */
.zt-cc.is-warn .zt-cc-face { background: linear-gradient(135deg, #fdf3d6 0%, #f8e3a8 100%); color: var(--zeal-dark-grey); box-shadow: 0 0 0 2px var(--zeal-gold), 0 8px 22px rgba(0, 0, 0, 0.2); }
.zt-cc.is-warn .zt-cc-brand { color: var(--zeal-dark-grey); }
.zt-cc.is-warn .zt-cc-funding { background: rgba(0, 0, 0, 0.1); color: var(--zeal-dark-grey); }
.zt-cc.is-warn .zt-cc-default { background: var(--zeal-dark-grey); color: #fff; }
/* The status pill is already amber; on an amber face it needs its own edge to stay separate from the card. */
.zt-cc.is-warn .zt-cc-warn { background: var(--zeal-dark-gold); color: #ffcb04; border-color: var(--zeal-dark-gold); }
/* TOP ROW: brand block (left) + action buttons (right), tops ALIGNED (align-items:flex-start). */
.zt-cc-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.5rem; }
/* BRAND LOGO (top-left) with the card TYPE (credit/debit/prepaid) + default badge stacked UNDER it. */
.zt-cc-brandblock { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; }
/* the brand logo sits on a small WHITE PLATE so its true colours read on any card background. */
.zt-cc-brand { display: inline-flex; align-items: center; justify-content: center; background: #fff; border-radius: 5px; padding: 5px 8px; min-width: 0; }
.zt-brandmark { display: block; height: 22px; width: auto; }
/* the TYPE + DEFAULT tags fill the gap between the brand and the number and CENTER vertically within it. */
.zt-cc-tags { display: flex; flex: 1 1 auto; gap: 0.35rem; align-items: center; align-content: center; flex-wrap: wrap; }
/* align-content IS THE ONE THAT MATTERS HERE: with flex-wrap:wrap the tag LINE is packed to the top by
   default, so align-items alone left the tags hugging the logo instead of centred between it and the number. */
.zt-cc-funding { font-size: 0.58rem; font-weight: 700; letter-spacing: 0.08em; padding: 0.14rem 0.5rem; border-radius: 999px; background: rgba(255, 255, 255, 0.16); }
.zt-cc-default { font-size: 0.58rem; font-weight: 800; letter-spacing: 0.08em; padding: 0.14rem 0.5rem; border-radius: 999px; background: var(--zeal-tiffany); color: #05201f; }
.zt-cc-number { font-size: 1.15rem; font-weight: 700; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.zt-cc-nickname { font-size: 0.78rem; opacity: 0.85; font-style: italic; margin-top: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-cc-row-bottom { display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-top: 0.5rem; }
.zt-cc-field { display: flex; flex-direction: column; min-width: 0; }
.zt-cc-field-exp { text-align: right; align-items: flex-end; flex: 0 0 auto; }
.zt-cc-label { font-size: 0.55rem; letter-spacing: 0.1em; text-transform: uppercase; opacity: 0.7; }
.zt-cc-value { font-size: 0.85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
/* ACTION buttons — top-right of the header row, NO background container, just the buttons. */
.zt-cc-actions { display: flex; gap: 0.3rem; flex: 0 0 auto; }
/* the issue button reads as a warning; the edit button is a plain default. Both get a little right-edge breathing room. */
.zt-member-issue-btn.ztbtn { color: var(--zeal-gold, #c99a2e); }
.zt-member-issue-btn.ztbtn:hover { color: var(--zeal-white); background: var(--zeal-gold, #c99a2e); }
/* NO inner scroll window — the list expands to its full height and the PAGE scrolls (the nested scroll behaved
   badly on mobile). */
.zt-rank-list { display: flex; flex-direction: column; gap: 0.6rem; padding: 2px; }
.zt-rank-entry {
	display: flex; align-items: center; gap: 0.9rem; padding: 0.7rem 0.85rem;
	background: var(--zeal-white); border: 1px solid #e6e8e8; border-left: 4px solid var(--zeal-tiffany); border-radius: 11px;
}
/* archived styling (whole card goes yellow) is defined AFTER the belt colours so it overrides them — see below. */
.zt-rank-entry-medal {
	flex: 0 0 auto; width: 2.6rem; height: 2.6rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
	background: #eafbfa; color: var(--zeal-tiffany); font-size: 1.25rem;
}

/* ── 🥋 THE 3×2 ENTRY GRID (Vince, 2026-08-19) ──────────────────────────────────────────────────────────────
   Column 1: the DRAWN belt over its rank name (the user-home program card's own stack — .zt-prog-belt is that
   card's class, reused so the two belts can never render differently). Column 2: promoted date over time held.
   Column 3: points over remaining. Each cell is a 2-row flex column of plain content items — the duration chip
   is gone on purpose ("just a regular content item").
   ⚠️ minmax(0, …) EVERYWHERE — a grid track's default min-content floor would let a long rank name push the
   card wider instead of ellipsising (the same trap every grid in this file documents). */
.zt-rank-grid {
	flex: 1 1 auto; min-width: 0;
	/* COLUMN WEIGHTS TUNED 2026-08-19: the dates column was ellipsising "1 Years, 5 Months, 11 Days" while the
	   points column sat mostly empty, so ~10% moved from column 3 to column 2 ("make that column a little bit
	   wider and retake whatever space we need... from the third column"). */
	display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.1fr) minmax(0, 0.9fr);
	/* ⚠️ stretch + top-aligned cells, NOT center — row one is a POSITION (Vince, 2026-08-19): a current rank has
	   Points over Remaining, a prior rank has Points alone, and centering the lone item floated it to the middle
	   so the two card shapes disagreed about where Points lives. Top-left origin keeps every column's first item
	   on the same line whatever its second row holds. */
	gap: 0.3rem 1.4rem; align-items: stretch;
	font-size: 0.9rem;
}
.zt-rank-cell { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; justify-content: flex-start; }
.zt-rank-name-item .zt-content-item-value { display: inline-flex; align-items: center; gap: 0.5rem; min-width: 0; }
@media (max-width: 599px) {
	/* PHONES: the three columns stack, belt first — the grid simply becomes the column it reads as. */
	.zt-rank-grid { grid-template-columns: minmax(0, 1fr); width: 100%; }
	/* A WHISPER under the belt (Vince, 2026-08-19: "a whisper of vertical space between the belt representation
	   and the name... consistent with the items below it"). The 0.3rem flex gap is there, but a solid colour bar
	   reads tighter against text than text does against text — this optical nudge makes belt→name MEASURE a
	   touch more so it LOOKS the same as name→date. Phone-scoped: on desktop the name sits beside other columns
	   and the extra air would push it out of line. */
	.zt-rank-cell .zt-prog-belt { margin-bottom: 0.15rem; }
}
/* BELT COLOURS — left accent + medal take the belt's colour, keyed off the rank-name prefix (BeltClass). */
/* thin frame = a SOFT low-opacity tint of the belt colour; the LEFT accent stays full-strength (belt signature) */
.zt-rank-entry.zt-belt-black { border-color: rgba(28, 28, 28, 0.4); border-left-color: #1c1c1c; }
.zt-rank-entry.zt-belt-black .zt-rank-entry-medal { background: #ededed; color: #1c1c1c; }
.zt-rank-entry.zt-belt-brown { border-color: rgba(109, 76, 51, 0.4); border-left-color: #6d4c33; }
.zt-rank-entry.zt-belt-brown .zt-rank-entry-medal { background: #efe6dd; color: #6d4c33; }
.zt-rank-entry.zt-belt-purple { border-color: rgba(122, 62, 163, 0.4); border-left-color: #7a3ea3; }
.zt-rank-entry.zt-belt-purple .zt-rank-entry-medal { background: #f1e8f7; color: #7a3ea3; }
.zt-rank-entry.zt-belt-blue { border-color: rgba(47, 111, 214, 0.4); border-left-color: #2f6fd6; }
.zt-rank-entry.zt-belt-blue .zt-rank-entry-medal { background: #e6eefb; color: #2f6fd6; }
.zt-rank-entry.zt-belt-white { border-color: rgba(154, 160, 166, 0.45); border-left-color: #9aa0a6; }
.zt-rank-entry.zt-belt-white .zt-rank-entry-medal { background: #eef0f1; color: #6b7075; }
.zt-rank-entry-main { flex: 1 1 auto; min-width: 0; }
.zt-rank-entry-name { display: flex; align-items: center; gap: 0.5rem; font-weight: 700; font-size: 1.05rem; color: var(--zeal-dark-grey); }
/* the inline name medal is hidden on desktop (the big medal circle carries it); shown only on small screens */
.zt-rank-entry-name-icon { display: none; }
.zt-rank-tag { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--zeal-white); background: var(--zeal-gold, #c99a2e); padding: 0.1rem 0.45rem; border-radius: 999px; }
.zt-rank-entry-meta { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; margin-top: 0.2rem; color: var(--zeal-grey); font-size: 0.9rem; }
/* DIRECT children only — the row spans (date/promoted-by/timespan) get the icon+text flex; the inner text spans
   stay plain so they wrap/ellipsis normally. */
.zt-rank-entry-meta > span { display: inline-flex; align-items: center; gap: 0.35rem; }
.zt-rank-entry-time {
	flex: 0 0 auto; display: inline-flex; align-items: center; gap: 0.35rem; white-space: nowrap;
	background: #f0f2f2; color: var(--zeal-dark-grey); font-weight: 600; font-size: 0.85rem; padding: 0.3rem 0.6rem; border-radius: 999px;
}
.zt-rank-entry-actions { flex: 0 0 auto; display: flex; gap: 0.35rem; }
.zt-rank-delete-copy { color: var(--zeal-dark-grey); line-height: 1.5; }
/* ARCHIVED — the card's OUTLINE + left accent + medal go yellow; the TEXT stays black. The "Archived" tag and the
   restore/delete buttons carry the rest of the signal. Comes after the belt rules so it wins at equal specificity. */
.zt-rank-entry.is-archived { border-color: var(--zeal-gold, #c99a2e); border-left-color: var(--zeal-gold, #c99a2e); background: #fdf9ec; }
.zt-rank-entry.is-archived .zt-rank-entry-medal { background: #fbf1d6; color: var(--zeal-gold, #c99a2e); }

/* ── ATTENDANCE (ZtAttendanceEditor) — reusable FULL-SCREEN panel off the rank-history card's Attendance button.
   --belt carries the rank's belt colour to the header accent + entry left border (one custom property, set by the
   .zt-belt-* class the editor puts on the overlay). ───────────────────────────────────────────────────────── */
/* BELT COLOUR — the rank's colour as ONE custom property, set by the .zt-belt-* class the caller puts on a wrapper
   (the attendance list). Generic (no longer tied to the retired bespoke overlay) so any container can carry it. */
.zt-belt-black  { --belt: #1c1c1c; }
.zt-belt-brown  { --belt: #6d4c33; }
.zt-belt-purple { --belt: #7a3ea3; }
.zt-belt-blue   { --belt: #2f6fd6; }
.zt-belt-white  { --belt: #9aa0a6; }
/* BODY — scrolls; the list is a readable centered column even though the panel is full-screen. */
/* min-height:0 lets the flex body SHRINK so the panel respects its max-height and the body (not the panel) scrolls;
   overscroll-behavior:contain stops the list scroll from chaining to the page behind. */
.zt-att-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.1rem 1.2rem 1.4rem; }
.zt-att-list { display: flex; flex-direction: column; gap: 0.55rem; }
/* points-per-week area graph — scrolls away with the list (first child, not sticky) */
.zt-att-chart { margin-bottom: 0.5rem; }
/* empty-state alert ("No attendance yet") — a big icon vertically centred against the title + message. */
.zt-att-empty { align-items: center; }
.zt-att-empty .zt-alert-icon { font-size: 2.4rem; }
/* ADD/EDIT form (date · points · grade, then note) */
.zt-att-form { background: #f6f7f7; border: 1px solid #e6e8e8; border-radius: 12px; padding: 0.9rem 1rem 1rem; margin-bottom: 1rem; }
/* TRIM the per-control margin (base .zt-input-container is 0.75em/0.5em) INSIDE these forms so stacked controls sit
   close together — cuts the big vertical gap between the fields on both the rank-history and attendance edit forms. */
.zt-rank-form .zt-input-container, .zt-att-form .zt-input-container { margin-top: 0.2em; margin-bottom: 0.2em; }
.zt-att-form-fields { display: grid; grid-template-columns: minmax(0, 14rem) minmax(0, 10rem) minmax(0, 12rem); gap: 1rem; justify-content: start; }
.zt-att-form-actions { display: flex; justify-content: flex-start; gap: 0.6rem; margin-top: 0.9rem; }
/* ONE record on a SINGLE ROW: date · points · grade · note (fills) · actions — vertically centered, wraps on narrow. */
.zt-att-entry {
	display: flex; align-items: center; flex-wrap: wrap; column-gap: 1.25rem; row-gap: 0.35rem; padding: 0.7rem 1rem;
	background: var(--zeal-white); border: 1px solid #e6e8e8; border-left: 4px solid var(--belt, var(--zeal-tiffany)); border-radius: 11px;
}
.zt-att-col { display: inline-flex; align-items: center; gap: 0.4rem; min-width: 0; color: var(--zeal-dark-grey); font-size: 0.92rem; }
.zt-att-col > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-att-col .zt-fa { color: var(--belt, var(--zeal-tiffany)); flex: 0 0 auto; }
.zt-att-points { font-weight: 600; }
.zt-att-note { flex: 1 1 12rem; color: var(--zeal-grey); font-size: 0.88rem; }
.zt-att-note .zt-fa { color: #b8b8b8; }
.zt-att-entry-actions { margin-left: auto; display: flex; gap: 0.35rem; flex: 0 0 auto; }
.zt-att-entry.is-archived { border-color: var(--zeal-gold, #c99a2e); border-left-color: var(--zeal-gold, #c99a2e); background: #fdf9ec; }
@media (max-width: 839px) {
	/* HEADER — smaller title so it fits; New + Close forced to EQUAL height (they diverged on mobile). */
	.zt-att-header { padding: 0.7rem 0.85rem; }
	.zt-att-title-icon { font-size: 1.2rem; }
	.zt-att-title { font-size: 0.98rem; }
	.zt-att-rank { font-size: 0.78rem; }
	.zt-att-header-actions .ztbtn { height: 2.05rem; min-height: 2.05rem; }
	/* FORM — tighter so the controls fit the width and sit close together (control margins trimmed above). */
	.zt-att-form { padding: 0.7rem 0.8rem 0.8rem; }
	.zt-att-form-fields { grid-template-columns: minmax(0, 1fr); gap: 0.35rem; }
	.zt-att-form-actions { margin-top: 0.5rem; }
	/* RECORDS — keep date + points + grade on ONE wrapped row; note + buttons drop to their own lines. Compact. */
	.zt-att-entry { column-gap: 0.85rem; row-gap: 0.25rem; padding: 0.5rem 0.7rem; }
	.zt-att-note { flex: 1 1 100%; }
	.zt-att-entry-actions { flex: 1 1 100%; margin-left: 0; margin-top: 0.1rem; justify-content: flex-start; }
}
@media (max-width: 599px) {
	.zt-rank-form-fields { grid-template-columns: 1fr; gap: 0.4rem; }
	/* MOBILE rank card: DROP the big medal entirely and stack everything in ONE column — name, date, timespan,
	   then the buttons below, all left-aligned. Tighter and cleaner on a phone. */
	.zt-rank-entry { display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem; padding: 0.8rem 0.9rem; }
	.zt-rank-entry-medal { display: none; }
	/* the data block is a flex column with ONE consistent row gap, so vertical spacing is even across every row */
	.zt-rank-entry-main { width: 100%; min-width: 0; display: flex; flex-direction: column; gap: 0.35rem; }
	/* one clean column: the NAME drops to the meta size (still BOLD) and gets its own inline medal icon (the big
	   medal circle is hidden here). Match the meta's icon→text gap (0.35rem) so the text COLUMN lines up on EVERY
	   row — name, date, promoted-by and timespan all start at the same x. */
	.zt-rank-entry-name { font-size: 0.9rem; gap: 0.35rem; }
	.zt-rank-entry-name-icon { display: inline-flex; }
	/* stack the meta; NO top margin so the name→date gap equals the others (the -main gap owns all vertical space) */
	.zt-rank-entry-meta { flex-direction: column; align-items: flex-start; gap: 0.35rem; margin-top: 0; }
	/* timespan: DROP the pill background so it's just an icon + text row like the others (same size + colour) */
	.zt-rank-entry-time { align-self: flex-start; background: none; padding: 0; font-size: 0.9rem; font-weight: 600; color: var(--zeal-grey); }
	.zt-rank-entry-actions { justify-content: flex-start; flex-wrap: wrap; }
}
.ztmo-title { font-weight: 700; font-size: 1.05rem; color: var(--zeal-dark-grey); line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ⭐ THREE NAMED STEPS FOR THE TITLE (ZtTitleSize, 2026-08-22). Row shapes that want a BIG avatar want a
   SMALLER name with it — a payout row is scanned by the account's PICTURE, and the name confirms it.
   Named steps rather than a free size parameter, so the ladder stays here with the rest of the type. */
.ztmo-title.ztmo-title-sm { font-size: 0.92rem; }
.ztmo-title.ztmo-title-lg { font-size: 1.2rem; }
/* ROLE line under the account name (e.g. "Owner") + optional tiny pencil to edit it. Pulled up snug against
   the title (counters the .ztmo-content 0.5rem gap) so it reads as a subtitle, not a separate row. */
.ztmo-subtitle { margin-top: -0.35rem; display: flex; align-items: center; gap: 5px; font-size: 0.82rem; color: #777; line-height: 1.2; }
.ztmo-subtitle-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Leading icon on a detail line (email/birthday/…) — tiffany like ZtProfileHeader's meta icons; on a filled
   header the band's `* { color: white }` rule recolors it white to match the text. */
.ztmo-line-icon { color: var(--zeal-tiffany); flex: 0 0 auto; font-size: 0.95rem; }
/* the role edit affordance is JUST THE ICON — strip ALL button chrome (no square, no bg, no border/padding) and
   keep the glyph no larger than the role word next to it. */
.ztmo-subtitle-edit.ztbtn.ztbtn-icon {
	width: auto; min-width: 0; height: auto; padding: 0; gap: 0;
	border: 0; background: transparent; color: #b0b0b0;
}
.ztmo-subtitle-edit.ztbtn.ztbtn-icon .zt-fa { font-size: 0.78rem; }
.ztmo-subtitle-edit.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-tiffany); }
/* ── THE TITLE'S REORDER ARROWS — the QUIETEST control in the app, on purpose ──────────────────────────────
   Vince, 2026-08-21: "the interface for this needs to be very discrete and not distracting, kinda like the user
   one" — "the user one" being the subtitle's edit pencil directly above, whose treatment these copy exactly:
   no box, no fill, no border, 0.78rem, mid-grey, tiffany on hover.

   ⚠️ THE MoveUp/MoveDown VERBS' PURPLE IS DELIBERATELY NOT USED HERE, and that is the ONE documented exception
   to the verb-colour table. The verbs exist so a MOVE is recognisable at a glance in a busy editor row; beside
   an academy's NAME the same colour reads as decoration and pulls the eye off the name itself. The GLYPHS are
   still the verbs' own (circle-up / circle-down), so the shape stays consistent even where the colour does not.

   The title becomes a flex row to seat them; .ztmo-title-text carries the ellipsis so a long academy name still
   truncates rather than pushing the arrows off the card. */
/* gap 0.3 -> 0.15rem between the ARROWS (Vince, 2026-08-21: "move them a little bit closer together"). The
   name keeps its own breathing room via the margin below, so only the arrow pair tightens up. */
.ztmo-title { display: flex; align-items: center; gap: 0.15rem; min-width: 0; }
.ztmo-title-text { margin-right: 0.15rem; }
.ztmo-title-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ztmo-title-move.ztbtn.ztbtn-icon {
	width: auto; min-width: 0; height: auto; padding: 0; gap: 0;
	border: 0; background: transparent; color: #b0b0b0;
	flex: 0 0 auto;
}
.ztmo-title-move.ztbtn.ztbtn-icon .zt-fa { font-size: 0.78rem; }
.ztmo-title-move.ztbtn.ztbtn-icon:hover { background: transparent; color: var(--zeal-tiffany); }
.ztmo-title-move.ztbtn.ztbtn-icon:disabled { background: transparent; color: #d8d8d8; }

/* stacked media objects get breathing room between them */
.ztmo + .ztmo { margin-top: 1.25rem; }
/* the connected-account header (logo + name) sitting above its program cards — a gap, no divider */
.zt-programs-account { margin-bottom: 1.1rem; }
/* MOBILE — the media object becomes a TIGHT stacked block that stays vertically CENTERED against the avatar
   (the whole .ztmo is still a center-aligned flex row: [avatar][body]). */
@media (max-width: 599px) {
	.ztmo { column-gap: 0.75rem; }
	/* STACK order = name → subtitle + every detail line → BUTTONS LAST, with ONE uniform gap between them.
	   Promote the content's children to flex siblings of the body so they can be ordered independently.

	   ⚠️ THE BUTTONS USED TO BE order:2 — SECOND, DIRECTLY UNDER THE NAME — AND THAT WAS THE BUG (Vince,
	   2026-08-07: "we're always putting these buttons, like that update button, just right there in the middle.
	   Can we put it at the bottom after the upcoming appointment. Same with the contact us"). It split every
	   card's identity from its own facts: "Bobby Hill / [Update] / when, email, phone". A button belongs after
	   the thing it acts on, so it now sorts after the lines.

	   ONE RULE, EVERY MEDIA OBJECT. The same order:2 was pushing the Filters/New buttons up beside a title that
	   then truncated to "Grac…" on the Recharge and Payouts headers — same control, same cause, same fix.
	   (Detail LINES render as .ztmo-subtitle too, which is why one order value covers the subtitle and all of
	   the lines.) */
	.ztmo-body { flex-direction: column; align-items: stretch; gap: 0.3rem; }
	.ztmo-content { display: contents; }
	.ztmo-title { order: 1; }
	.ztmo-subtitle { order: 3; margin-top: 0; }
	/* THE BUTTON ROW GETS A BEAT ABOVE IT. The 0.3rem body gap is right BETWEEN text lines but reads cramped
	   between the last line and a row of buttons — a button row is a different kind of thing. GLOBAL on purpose:
	   Vince asked for this same gap on the Recharge, Payouts and Agreements headers one after another, which is
	   the tell that it belongs to the control and not to any one page. */
	.ztmo-actions { order: 4; margin-left: 0; justify-content: flex-start; margin-top: 0.45rem; }
	/* THE TWO-ROW CA NAV FOLLOWS THE ROW IT SITS IN — left-aligned here rather than right, and full width so the
	   two rows line up with each other and with the name above them instead of each ending in a different place. */
	.ztmo-actions .zt-ca-enter { width: 100%; align-items: flex-start; }
	.ztmo-actions .zt-ca-nav-row { justify-content: flex-start; }
	.zt-programs-account { margin-bottom: 0.7rem; }
}

/* ---- detail card (ZtDetailCard) — icon + title/subtitle (left) | two-column detail grid (right). Same
   card chrome as the item card (ztic) so they read as a family. ---- */
.ztdc {
	position: relative;                                  /* anchors the top-right actions (edit button) */
	display: flex; align-items: center; gap: 14px;       /* icon vertically centered against the row */
	border: 1px solid #e4e4e4; background-color: #fbfbfb; border-radius: 8px;
	padding: 12px 52px 12px 16px;                        /* extra right padding reserves the top-right edit spot */
	margin-top: 0.5em; margin-bottom: 0.5em;
	transition: box-shadow 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}
/* ══ 🎨 THE ROW-CARD TONE TABLE — THE REFERENCE LOOK FOR EVERY TINTED ROW IN THE APP ══════════════════════
   Vince, 2026-08-16, pointing at the Default-template row: "that background and that accent for the green is the
   style that we're looking for. The very light green background, but the darker accent, the font, color,
   everything there is how we want it … We want the same for the yellow warning, the blue neutral, gray neutral,
   and red error. That same styling, but with obviously the appropriate colors for each."

   SO A TONE IS EXACTLY FOUR THINGS, AND EVERY TONE HAS ALL FOUR:
     ① the pale token FILL      — the same wash the section band above it uses (--zt-*-bg)
     ② a 1px FULL-STRENGTH accent BORDER   — never the pale --zt-*-bd tint; that is the "light accent" this pass
                                             exists to kill (it made a row look unfinished beside a green one)
     ③ a 4px accent TAB down the left edge — the flag that makes a row scannable in a list
     ④ accent-coloured ICONS    — declared with the icon rules further down
   ⑤ and the hover glow, immediately below.

   ⚠️ GREY IS A REAL TONE NOW, NOT "no tone". ZtCardStyle.Default renders .ztdc-grey, so there is no such thing as
   an untinted detail card any more — a row with nothing to say still reads as a member of the family instead of
   as the one control nobody finished.

   ⚠️ GOLD IS --zt-gold-accent (#d4a017), NOT the old literal #e0a800. That hex was the ONE colour in this block
   that named nothing, and it never matched the gold this same card already used for its icons — so a warning row
   drew its border in one yellow and its glyphs in another. One token, one yellow. */
.ztdc-green  { border-color: var(--zeal-green);     background-color: var(--zt-happy-bg);   border-left: 4px solid var(--zeal-green);     --zt-row-glow: var(--zt-happy-glow); }
.ztdc-gold   { border-color: var(--zt-gold-accent); background-color: var(--zt-warn-bg);    border-left: 4px solid var(--zt-gold-accent); --zt-row-glow: var(--zt-warn-glow); }
.ztdc-blue   { border-color: var(--zeal-blue);      background-color: var(--zt-neutral-bg); border-left: 4px solid var(--zeal-blue);      --zt-row-glow: var(--zt-neutral-glow); }
.ztdc-red    { border-color: var(--zeal-red);       background-color: var(--zt-error-bg);   border-left: 4px solid var(--zeal-red);       --zt-row-glow: var(--zt-error-glow); }
.ztdc-grey   { border-color: var(--zeal-silver);    background-color: var(--zt-plain-bg);   border-left: 4px solid var(--zeal-silver);    --zt-row-glow: var(--zt-plain-glow); }
/* PURPLE = SELECTED / current (the passkey on this device), matching legacy production. It is the one tone that
   is not a status, which is why it sits apart — but it is built from the identical four parts. */
.ztdc-purple { border-color: var(--zeal-purple);    background-color: var(--zt-select-bg);  border-left: 4px solid var(--zeal-purple);    --zt-row-glow: var(--zt-select-glow); }
.ztdc-purple .ztdc-icon { color: var(--zeal-purple); }

/* ⑤ THE HOVER LIFT — ONE RULE FOR EVERY TONE. The tone supplies its own --zt-row-glow above, so this never has to
   name a colour and a new tone can never be added without one. Same shape as .ztic-clickable:hover, which is the
   effect Vince was pointing at ("the mouse over effect that some of the other controls have").

   ⚠️ (hover: hover) — charter rule 7a. On a touch screen there is no pointer to move away, so an unguarded :hover
   sticks to the last row tapped; on an unfolded foldable the guard is what keeps that true regardless of width. */
@media (hover: hover) {
	.ztdc:hover { box-shadow: 0 2px 8px var(--zt-row-glow, rgba(0, 0, 0, 0.10)); }
}
/* THE LEADING ICON SITS ON A DISC (Vince, 2026-08-05) — bare glyphs read as "naked" against the card fill.
   ⚠️ THE DISC IS SIZED IN em, NOT px. This same class is scaled up per card (2.1rem on the facility, agreement
   and schedule cards vs 1.4rem here), so a fixed 48px disc would crop the big ones and swim around the small
   ones. 2em keeps the circle "just fitting" at every size, automatically. */
.ztdc-icon {
	flex: 0 0 auto;
	font-size: 1.4rem;
	color: #9a9a9a;
	line-height: 1;
	width: 2em;
	height: 2em;
	border-radius: 50%;
	background: var(--zt-icon-disc);
	display: inline-flex;
	align-items: center;
	justify-content: center;
}
/* PHOTO-LED leading avatar (family member) — circular, in place of the icon; graceful fallback reveal. */
.ztdc-avatar {
	position: relative; flex: 0 0 auto;
	width: 48px; height: 48px; border-radius: 50%; overflow: hidden;
	display: inline-flex; align-items: center; justify-content: center;
	background: #444; color: #bbb; font-size: 20px; border: 2px solid var(--zeal-white);
}
.ztdc-avatar-img { width: 100%; height: 100%; object-fit: cover; }
.ztdc-avatar-fallback { display: inline-flex; align-items: center; justify-content: center; }
.ztdc-avatar:has(.ztdc-avatar-img):not(.img-failed) .ztdc-avatar-fallback { display: none; }
.ztdc-avatar.img-failed .ztdc-avatar-img { display: none; }
/* FAMILY card — the details column stacks (email over phone) so the card reads as two columns: name+birthday on
   the left, email+phone on the right ("two columns of two"). The top-right Home button uses the reserved pad. */
.zt-family-card .ztdc-details { grid-template-columns: 1fr; }
/* PAYOUT STRUCTURE — three facts (account, location, percentage) read as ONE list, not a two-column grid that
   would split them 2/1 and leave a hole. Same single-column treatment as the family card. */
.zt-payout-card .ztdc-details { grid-template-columns: 1fr; }
/* FIXED name column so the email/phone column lines up ACROSS all family cards regardless of name length (short
   names get whitespace; very long names wrap). Tune the width here. */
.zt-family-card .ztdc-main { flex: 0 0 260px; min-width: 0; }
/* long names TRUNCATE with an ellipsis instead of wrapping to a second line (desktop + mobile) */
.zt-family-card .ztdc-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* DESKTOP: the Home button is ICON-ONLY (hide its "Home" label — the text shows only on the mobile banded card).
   Reserve room on the right for the pinned icon-only button. */
.zt-family-card .ztdc-actions .ztbtn-text { display: none; }
/* the hidden label still exists in the DOM, so .ztbtn-icon:has(.ztbtn-text) would puff the button into a padded
   pill — force it back to the tight 30px square (no L/R padding) on desktop. */
.zt-family-card .ztdc-actions .ztbtn { width: 30px; min-width: 30px; height: 30px; min-height: 30px; padding: 0; gap: 0; }
.zt-family-card { padding-right: 60px; }
/* the "load member" form wrapping the Home button — transparent to layout so the button sits in .ztdc-actions. */
.zt-member-enter { display: contents; }
/* the Home button is centered VERTICALLY in the card, and its right gap equals the left gap (card padding-left
   16px → avatar), so the padding is symmetric on both sides. */
.zt-family-card .ztdc-actions { top: 50%; right: 16px; transform: translateY(-50%); }
/* DESKTOP: the program-card Edit button is ICON-ONLY (its "Edit" label shows only on the mobile banded card). */
.zt-program-card .ztdc-actions .ztbtn-text { display: none; }
/* same pill-puff guard as the family Home button — keep the icon-only Edit a tight 30px square (no L/R padding). */
/* Collapse to the SAME 30px square as the connected-account toolbar's icon buttons. Target .ztbtn (NOT just
   .ztbtn-icon): these are ZtSmallButtons, whose .ztbtn-small pill padding otherwise survives the hidden label. */
.zt-program-card .ztdc-actions .ztbtn { width: 30px; min-width: 30px; height: 30px; min-height: 30px; padding: 0; gap: 0; }
/* ④ THE ICONS TAKE THE TONE — the big leading glyph AND every detail-fact glyph, in the SAME accent the border
   and tab carry. All five statuses are listed, so no tone is left drawing grey glyphs on a coloured row. */
.ztdc-green  .ztdc-icon { color: var(--zeal-green); }
.ztdc-gold   .ztdc-icon { color: var(--zt-gold-accent); }
.ztdc-blue   .ztdc-icon { color: var(--zeal-blue); }
.ztdc-red    .ztdc-icon { color: var(--zeal-red); }
.ztdc-grey   .ztdc-icon { color: var(--zeal-silver); }
/* the detail-fact icons match the CARD's accent — the SAME colour as the medal icon, never a stray default.
   The overdue "Status" row (danger) always stays red (last rule + higher specificity wins). */
.ztdc .zt-content-item-icon { color: #9a9a9a; }
.ztdc-green  .zt-content-item-icon { color: var(--zeal-green); }
.ztdc-gold   .zt-content-item-icon { color: var(--zt-gold-accent); }
.ztdc-blue   .zt-content-item-icon { color: var(--zeal-blue); }
.ztdc-red    .zt-content-item-icon { color: var(--zeal-red); }
.ztdc-grey   .zt-content-item-icon { color: var(--zeal-silver); }
.ztdc-purple .zt-content-item-icon { color: var(--zeal-purple); }
.ztdc .zt-content-item-danger .zt-content-item-icon { color: var(--zeal-red); }
.ztdc-main { flex: 0 0 auto; min-width: 150px; }
.ztdc-title { font-weight: 700; font-size: 1.05rem; color: var(--zeal-dark-grey); line-height: 1.2; }
.ztdc-subtitle { font-size: 0.85rem; color: #7a7a7a; margin-top: 0.1rem; }
.ztdc-details {
	flex: 1 1 auto; min-width: 0;
	display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--zt-item-gap) 1.5rem;
}

/* ⚠️ A PILL DROPPED INTO THIS GRID MUST NOT STRETCH. A ZtBadge is `display: inline-flex`, but a direct child of
   a grid is BLOCKIFIED and gets `justify-self: stretch` — so it inflated to the full 1fr column and rendered as
   a huge slab instead of a chip (Vince, 2026-08-15: "look how big that personalized chip is… look how wide
   that is"). The pill's own size was never wrong; the grid was overriding it.

   `end` also gives the layout he asked for without a second rule: the FACT sits left in its column, the STATE
   sits hard right against the card's padding — the same left-text / right-control rhythm every other row has.
   `align-self: center` stops the same blockification stretching it vertically against a taller neighbour. */
.ztdc-details > .ztic-badge { justify-self: end; align-self: center; }
/* EDIT/action button — VERTICALLY CENTERED on the right (mirrors the vertically-centered leading icon), out of
   flow so it doesn't add height. */
.ztdc-actions { position: absolute; top: 50%; right: 14px; transform: translateY(-50%); display: flex; align-items: center; gap: 4px; }
@media (max-width: 599px) {
	/* tighten the icon→title gap (~3px) and the title-row→details gap (~2px) so the card reads a touch tighter. */
	.ztdc { flex-wrap: wrap; column-gap: 11px; row-gap: 12px; }
	.ztdc-details { grid-template-columns: 1fr; flex-basis: 100%; }
	/* ── THE ACTIONS COME BACK INTO THE FLOW AND SIT LAST (Vince, 2026-08-07: "the edit button, the payout
	   schedule… should be underneath"). ────────────────────────────────────────────────────────────────────
	   On a desktop row `.ztdc-actions` is position:ABSOLUTE, pinned to the middle of the right edge, and the base
	   card reserves 52px of right padding for it. On a phone the facts wrap to their own full-width line — so the
	   pinned button ended up floating over the middle of a two-line card with a 52px dead margin beside it. In
	   flow it takes its own row under the facts, left-aligned like every other action row in the app.

	   FOUR CARDS ALREADY DID THIS THEMSELVES (family, program, document, connected-account search) — each with a
	   near-identical hand-written copy of these two rules. They keep their own versions because each also
	   re-does its padding as part of a banded layout, and their (0,2,0) selectors outrank this one either way;
	   this is the DEFAULT they were each working around, now stated once for every card that comes after.

	   ⚠️ THE :not() LIST IS THE PADDING, NOT THE ACTIONS. Those four set `padding: 0` for their bands, and this
	   rule matches their specificity (0,1,0) while sitting later in the file — so without excluding them it would
	   silently put the base padding back and undo their layout. Add a card here only if it manages its own. */
	.ztdc-actions { position: static; transform: none; flex: 1 1 100%; justify-content: flex-start; margin: 0; }
	.ztdc:not(.zt-family-card):not(.zt-program-card):not(.zt-document-row):not(.zt-ca-result):not(.zt-charge-row) { padding-right: 16px; }
	/* SINGLE-COLUMN MOBILE: the belt+rank cell, then the status+points cell — which is simply the two desktop
	   columns read left to right, so the phone shows the card in the same order the eye takes it on a monitor.

	   ⚠️ THE PER-ITEM `order` RULES ARE GONE, and they had to be. They sequenced Rank/Next/Points/Status as four
	   LOOSE grid items; those items are now nested inside two cells, so they are no longer children of
	   .ztdc-details and `order` on them does nothing at all. Ordering the CELLS is what has an effect — and it is
	   also what guarantees a belt can never be separated from the rank it names. */
	.ztdc-details .zt-prog-rank-cell   { order: 1; }
	.ztdc-details .zt-prog-status-cell { order: 2; }
	/* ── FAMILY card, MOBILE: ONE PLAIN TINTED BLOCK — avatar + name/birthday, then email + phone, then the Home
	   button, each on the card's own light-green background.

	   ⚠️ THE WHITE BODY BAND IS GONE (Vince, 2026-08-07). This used to be a banded header / WHITE BODY / footer
	   card; see the note on .zt-document-row for the reasoning and for why all three banded cards lost it in the
	   same pass. The card gets its padding back and one row-gap does the spacing, instead of three bands each
	   paying for their own and doubling up at every seam. */
	.zt-family-card { padding: 12px 16px; row-gap: 10px; column-gap: 11px; background-color: var(--zt-happy-bg); }
	/* avatar + name/birthday share the first flex line, vertically centered. */
	.zt-family-card .ztdc-avatar { margin: 0; }
	.zt-family-card .ztdc-main { flex: 1 1 auto; min-width: 0; margin: 0; align-self: center; }
	/* email + phone, full width under both, on the card's own background — no band, no dividers, no padding. */
	.zt-family-card .ztdc-details { flex: 1 1 100%; margin: 0; padding: 0; row-gap: 0.12rem; background: none; border: none; }
	/* THE HOME BUTTON, left-aligned and full width. On the compact card it DOES show its "Home" label (icon +
	   text); desktop keeps it icon-only. */
	.zt-family-card .ztdc-actions { position: static; transform: none; flex: 1 1 100%; justify-content: flex-start; margin: 0; padding: 0; }
	.zt-family-card .ztdc-actions .ztbtn-text { display: inline; }
	/* undo the desktop 30px square so the labelled button is a normal pill again */
	.zt-family-card .ztdc-actions .ztbtn { width: auto; min-width: 0; height: auto; min-height: 0; padding: 0.35rem 0.8rem; gap: 0.4em; }
	/* ── PROGRAM-membership card, MOBILE: ONE PLAIN TINTED BLOCK — medal + program name + "since", then
	   rank/next/points/status, then the Edit button, all on the card's accent tint.

	   ⚠️ THE WHITE BODY BAND IS GONE (Vince, 2026-08-07) — see .zt-document-row for the reasoning. This card is
	   the one the document row was built to match, so the two had to lose it together or a phone would show a
	   membership row and a document row as two different objects. */
	.zt-program-card { padding: 12px 16px; row-gap: 10px; column-gap: 11px; }
	/* ⚠️ NO PER-WIDTH FILL RULES HERE ANY MORE. This block restated happy/warn/error at phone width and sent
	   everything else to BLUE — which was written when the base card had no tone of its own. The shared tone table
	   now sets the fill at every width, including grey, so restating it here could only ever drift from it (and the
	   :not() line had already started to: an untoned row is GREY now, and this was still painting it blue). */
	/* medal icon + name/"since" share the first flex line, vertically centered. */
	.zt-program-card .ztdc-icon { margin: 0; align-self: center; }
	.zt-program-card .ztdc-main { flex: 1 1 auto; min-width: 0; margin: 0; align-self: center; }
	/* THE FACTS: full width under the title, on the card's own background — no band, no dividers, no padding. */
	.zt-program-card .ztdc-details { flex: 1 1 100%; margin: 0; padding: 0; background: none; border: none; }
	/* THE EDIT BUTTON, left-aligned and full width; it shows its "Edit" label here. */
	.zt-program-card .ztdc-actions { position: static; transform: none; flex: 1 1 100%; justify-content: flex-start; margin: 0; padding: 0; }
	.zt-program-card .ztdc-actions .ztbtn-text { display: inline; }
	/* undo the desktop 30px square so the labelled Edit button is a normal pill again */
	.zt-program-card .ztdc-actions .ztbtn { width: auto; min-width: 0; height: auto; min-height: 0; padding: 0.35rem 0.8rem; gap: 0.4em; }
}

/* ⛔ THE PROGRAM CARD'S PRIVATE PALE-BORDER OVERRIDE IS GONE (Vince, 2026-08-16, looking at his own memberships:
   "on the user home page, we need to do the same for the program memberships … those have a LIGHT accent. That
   accent needs to be DARK. We need this to be consistent through the whole site").

   It used to pull .ztdc-green / .ztdc-gold back to --zt-happy-bd / --zt-warn-bd so a membership row matched the
   pale border of its own SECTION card. That was the wrong thing to match: a SECTION is a frame and wears a
   hairline; a ROW is an object in a list and wears its state. Matching the frame is what made the membership rows
   the one place in the app where the accent was a whisper — visibly weaker than the phone, address, passkey,
   document and template rows sitting one screen away. They now take the shared tone table like everything else.
   (The FILL was already the shared token, and has not changed.) */

/* ── MEMBERSHIP EDITOR (reusable Edit-Membership control) ─────────────────────────────────────────────────
   Read-only first (view), Edit switches to a form. Identity header = big CA logo + three lines (business ·
   member · program, biggest→smallest, evenly spaced, centered on the logo) with a Close pinned top-right;
   a two-column detail (dates on the right) that collapses to one on narrow screens; note truncates; action
   rows ALWAYS left-aligned (house rule). */

/* IDENTITY header — three lines, biggest → smallest, EVEN spacing, vertically centered against the logo
   (.ztmo is already align-items:center). Override the media object's default title/subtitle sizing. */
.zt-membership-identity .ztmo-content { gap: 0.05rem; }
.zt-membership-identity .ztmo-title { font-size: 1.6rem; line-height: 1.12; white-space: normal; }
.zt-membership-identity .ztmo-subtitle { margin-top: 0; font-size: 1.15rem; font-weight: 600; color: var(--zeal-dark-grey); line-height: 1.12; }
.zt-membership-program { font-size: 1rem; font-weight: 600; color: var(--zeal-dark-grey); line-height: 1.12; }

/* Close (X) — pinned to the top-right of the card (the base .zt-action-card is position:relative). The filled
   grey HEADER + FOOTER bands come from ZtActionCard's FilledHeaderFooter mode (.zt-action-card-flush) — no
   per-page padding/overflow/background hacks live here anymore. */
.zt-membership-close.ztbtn.ztbtn-icon { position: absolute; top: 12px; right: 12px; z-index: 2; }

/* ── ENTERPRISE MEMBERSHIP CARD — smaller avatar + tighter type in the (filled) header; compact multi-column body. */
.zt-membership-editor .zt-action-card-header .ztmo-title { font-size: 1.2rem; line-height: 1.15; }
.zt-membership-editor .zt-action-card-header .ztmo-subtitle,
.zt-membership-editor .zt-action-card-header .ztmo-subtitle-text { font-size: 0.85rem; }
.zt-membership-editor .zt-membership-program { font-size: 0.85rem; color: var(--zeal-grey); line-height: 1.2; }

/* BODY — a compact multi-column grid (2–3 across). */
.zt-membership-view-grid {
	display: grid; grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr));
	gap: 0.1rem 1.75rem; align-items: start;
}
.zt-membership-view-grid .zt-content-item { gap: 7px; font-size: 0.9rem; min-height: 1.45rem; align-items: center; color: #333333; }
.zt-membership-view-grid .zt-content-item-icon { font-size: 0.92rem; }
.zt-membership-view-grid .zt-membership-fullrow { grid-column: 1 / -1; }
/* softer text — #333, not "super super black" (Vince). Coloured variants (status green/red, welcome, overdue)
   set their own value colour, so they still win over this inherited default. */
.zt-membership-editor .zt-action-card-header .ztmo-title { color: #333333; }
/* VIEW — two explicit content-sized columns (Vince's data order), left-packed; stacks on mobile. */
.zt-membership-view-cols { display: grid; grid-template-columns: max-content max-content max-content; column-gap: 2.75rem; align-items: start; }
.zt-membership-vcol { display: flex; flex-direction: column; gap: 0.12rem; min-width: 0; }
.zt-membership-vcol .zt-content-item { gap: 7px; font-size: 0.9rem; min-height: 1.5rem; align-items: center; color: #333333; }
.zt-membership-vcol .zt-content-item-icon { font-size: 0.92rem; }
@media (max-width: 599px) { .zt-membership-view-cols { grid-template-columns: 1fr; row-gap: 0.35rem; } }

/* FOOTER toolbar (the grey band comes from .zt-action-card-flush) — hidden in edit mode (the form carries its
   own Cancel/Save). */
.zt-membership-editor.is-editing .zt-action-card-footer { display: none; }
.zt-membership-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem; }

/* DETAIL — two columns (dates live in the RIGHT column); the note spans both and truncates. */
.zt-membership-grid {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	column-gap: 1.3rem;
	row-gap: 0.28rem;   /* SAME as the within-column gap so single-column collapse spaces every row evenly */
	align-items: start;
}
.zt-membership-col { display: flex; flex-direction: column; min-width: 0; }
.zt-membership-col-view { gap: 0.28rem; }           /* view items self-space; edit inputs carry their own margin */
.zt-membership-note { grid-column: 1 / -1; min-width: 0; }
/* tighten the icon → text gap in the read-only rows (default ZtContentItem gap is 10px, too airy here). */
.zt-membership-view .zt-content-item { gap: 6px; }
/* COMPACT single-column view list (faithful port of the legacy display) + the green underlined welcome action. */
.zt-membership-view-list { display: flex; flex-direction: column; gap: 0.5rem; }
.zt-membership-view-list .zt-content-item { gap: 8px; }
.zt-membership-welcome .zt-content-item-icon,
.zt-membership-welcome .zt-content-item-value { color: var(--zeal-green); }
.zt-membership-welcome .zt-content-item-value { text-decoration: underline; font-weight: 700; }
.zt-membership-welcome:hover .zt-content-item-value { text-decoration: none; }
/* VIEW: pack the two columns close together (content-sized) instead of stretching across the whole card. */
.zt-membership-view { grid-template-columns: max-content max-content; column-gap: 3rem; justify-content: start; }
/* the note wraps to up to 3 lines (then ellipsis) and pushes the buttons down, rather than truncating to one line. */
.zt-membership-view .zt-membership-note { align-items: flex-start; }
.zt-membership-view .zt-membership-note .zt-content-item-value {
	white-space: normal;
	display: -webkit-box;
	-webkit-line-clamp: 3;
	-webkit-box-orient: vertical;
	overflow: hidden;
}

/* Two columns sized to the STANDARD field width (--zeal-field-max) — controls stay their normal size, NOT
   stretched to fill a wide modal. Left-packed so both columns line up; note (grid-column 1/-1) still spans both.
   Inputs keep their own max-width; only tighten their vertical margin a touch, symmetrically + consistently. */
.zt-membership-edit { grid-template-columns: repeat(2, minmax(0, var(--zeal-field-max))); justify-content: start; row-gap: 0; }
.zt-membership-edit .zt-input-container { margin: 0.4rem 0; }
/* the note row sits the SAME distance below the last field as the fields are from each other (no extra grid gap). */
.zt-membership-edit .zt-membership-note { margin-top: 0.4rem; margin-bottom: 0; }

/* live BILLING ENABLED / DISABLED note beneath the form — green when the status bills, red when it doesn't.
   EQUAL gap above (from the note) and below (to the buttons); icon + text centered on the same line. */
.zt-billing-note { display: flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.95rem; line-height: 1; margin: 0.85rem 0; }
.zt-billing-note .zt-fa { line-height: 1; }
.zt-billing-note span { line-height: 1; }
.zt-billing-note.zt-billing-on { color: var(--zeal-green); }
.zt-billing-note.zt-billing-off { color: var(--zeal-red); }
/* in edit the billing note's bottom margin IS the gap to the buttons, so don't add another. */
.zt-membership-actions-edit { margin-top: 0; }

/* comms opt-in confirm-modal body copy. */
.zt-membership-warning { margin: 0; line-height: 1.5; }
/* whisper: pull the buttons a little closer to the message, and give the modal a little more breathing room below. */
.zt-comms-modal .zt-modal-body { padding-bottom: 0.6rem; }
.zt-comms-modal .zt-modal-footer { padding-top: 0.35rem; padding-bottom: 1rem; }

/* action button rows — left-aligned, clearly BELOW the details (Vince wanted more room here), wrap on small screens. */
.zt-membership-actions {
	display: flex;
	flex-wrap: wrap;
	justify-content: flex-start;
	align-items: flex-start;
	gap: 0.5rem;
	margin-top: 1rem;
}

@media (max-width: 599px) {
	/* single column on phones so each field gets full width. */
	.zt-membership-grid { grid-template-columns: 1fr; }
	.zt-membership-identity .ztmo-title { font-size: 1.35rem; }
	/* KEEP the identity in CA → member → program order on mobile. The generic ztmo mobile rule sets
	   .ztmo-content{display:contents} + reorders for the nav-button case, which floats the program line to the
	   top here — override it back to a plain stacked column in DOM order. */
	.zt-membership-identity .ztmo-content { display: flex; flex-direction: column; gap: 0.05rem; }
	.zt-membership-identity .ztmo-title { order: 1; }
	.zt-membership-identity .ztmo-subtitle { order: 2; }
	.zt-membership-identity .zt-membership-program { order: 3; }
}

/* SECTION TITLE — matches the legacy Zeal.Razor.Ui page-section.scss EXACTLY: medium-grey (#666), 1.25rem,
   semi-bold (not the heavy/glaring 700). Inherits the Gotham body font. */
.zt-page-section-title {
	display: flex;
	align-items: center;
	gap: 0.5em;
	/* 0.2em, NOT 0.7em (Vince, 2026-08-04): a title sitting almost as far from its own paragraph as from the
	   section above it reads as floating rather than heading. Tightening it binds the title to the text it
	   introduces. Applies to EVERY ZtPageSection site-wide — that is the point, not a side effect. */
	margin-bottom: 0.2em;
	font-size: 1.25rem;
	line-height: 1.3;
	font-weight: 600;
	color: var(--zeal-medium-grey);
}
.zt-page-section-title-icon { color: var(--zeal-tiffany); }
.zt-page-section-title-text { flex: 1 1 auto; min-width: 0; }
/* right-aligned title-row actions (e.g. an "Add …" button) — pushed right by the flex-grow title text. Reset the
   inherited 1.2rem/700 title typography so buttons render at their own size. */
.zt-page-section-title-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 400; }

/* GENERIC collapse toggle — shared by ZtPageSection + ZtActionCard. */
.zt-collapse-toggle {
	flex: 0 0 auto;
	display: inline-flex;
	align-items: center;
	border: none;
	background: transparent;
	padding: 0.15em 0.3em;
	color: #9a9a9a;
	cursor: pointer;
	transition: color 0.12s ease;
}
.zt-collapse-toggle:hover { color: var(--zeal-tiffany); }

.zt-page-section-notes { margin-bottom: 0.7em; }
.zt-page-section-header { padding-bottom: 0.7em; margin-bottom: 0.85em; border-bottom: 1px solid #ececec; }
.zt-page-section-footer { padding-top: 0.8em; margin-top: 0.95em; border-top: 1px solid #ececec; }
.zt-page-section-skeleton { margin-bottom: 0.85em; }
.zt-page-section-skeleton:last-child { margin-bottom: 0; }

/* colour accents — top border + matching title-icon hue (shares ZtCardStyle with ZtActionCard).
   additive: only an accented section gets the top border, so existing sections are untouched. */
.zt-page-section-green   { border-top: 6px solid var(--zeal-green); }
.zt-page-section-green   .zt-page-section-title-icon { color: var(--zeal-green); }
.zt-page-section-red     { border-top: 6px solid var(--zeal-red); }
.zt-page-section-red     .zt-page-section-title-icon { color: var(--zeal-red); }
.zt-page-section-tiffany { border-top: 6px solid var(--zeal-tiffany); }
.zt-page-section-tiffany .zt-page-section-title-icon { color: var(--zeal-tiffany); }
.zt-page-section-gold    { border-top: 6px solid var(--zeal-gold); }
.zt-page-section-gold    .zt-page-section-title-icon { color: var(--zeal-gold); }
.zt-page-section-blue    { border-top: 6px solid var(--zeal-blue); }
.zt-page-section-blue    .zt-page-section-title-icon { color: var(--zeal-blue); }
.zt-page-section-purple  { border-top: 6px solid var(--zeal-purple); }
.zt-page-section-purple  .zt-page-section-title-icon { color: var(--zeal-purple); }
.zt-page-section-orange  { border-top: 6px solid var(--zeal-orange); }
.zt-page-section-orange  .zt-page-section-title-icon { color: var(--zeal-orange); }

/* COLLAPSE / EXPAND — a real smooth slide by animating grid-template-rows 1fr→0fr (no JS, no
   magic max-height), plus a fade + slight lift, and a chevron flip. The inner element keeps
   overflow hidden so the content clips cleanly as the row height collapses. */
.zt-collapse {
	display: grid;
	grid-template-rows: 1fr;
	transition: grid-template-rows 0.3s ease;
}
.zt-collapsed .zt-collapse { grid-template-rows: 0fr; }

.zt-collapse-inner {
	min-height: 0;
	overflow: hidden;
	transition: opacity 0.26s ease, transform 0.26s ease;
}
.zt-collapsed .zt-collapse-inner {
	opacity: 0;
	transform: translateY(-6px);
}

.zt-chevron { transition: transform 0.3s ease; }
.zt-collapsed .zt-chevron { transform: rotate(180deg); }

/* RESPECT REDUCED-MOTION — snap instead of slide for users who ask for it. */
@media (prefers-reduced-motion: reduce) {
	.zt-collapse,
	.zt-collapse-inner,
	.zt-chevron { transition: none; }
}

/* ---- PAGE TITLE (standalone sub-heading) ------------------------------ */
.zt-page-title { display: flex; align-items: center; gap: 0.5em; margin: 0 0 0.6em; font-size: 1.05rem; font-weight: 700; color: var(--zeal-dark-grey); }
.zt-page-title-icon { color: var(--zeal-tiffany); }
.zt-page-title-text { flex: 1 1 auto; min-width: 0; }
.zt-page-title-bordered { padding-bottom: 0.4em; border-bottom: 1px solid #ececec; }
/* RIGHT-ALIGNED ACTIONS ON A TITLE ROW. No margin-left:auto needed — .zt-page-title-text above already carries
   flex:1 1 auto, so it does the pushing; this only has to refuse to grow and keep its buttons on one line. */
.zt-page-title-actions { display: flex; align-items: center; gap: 0.35rem; flex: 0 0 auto; }

/* ---- PAGE CONTENT (titled content block, not a card) ------------------ */
/* Space between stacked content blocks (and before a following alert / button). The section body trims the
   trailing margin on its last child, so this never adds space at the bottom of a section. */
.zt-page-content { position: relative; margin-bottom: 0.9em; }
/* The content FADES in when it loads (legacy "cool" entrance, done as one-shot CSS). NOTE: fade ONLY — a `transform`
   here (even settling to none) makes this container a CONTAINING BLOCK for position:fixed descendants, which broke
   the full-screen date/dropdown pickers opened from inside a page (their fixed backdrop pinned to this box instead
   of the viewport). Opacity-only keeps the entrance without that side effect. */
/* NO entrance animation — on the prerender→interactive handoff the circuit recreates this element, so a fade/scale
   here REPLAYS and reads as a "blink" (content loading twice). Content now appears once, cleanly. */
.zt-page-content-body { }
@keyframes zt-page-content-in {
	from { opacity: 0; }
	to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) { .zt-page-content-body { animation: none; } }

/* ---- ACTION CARD (dashboard / grid card with a colour accent) --------- */
.zt-action-card {
	position: relative;
	box-sizing: border-box;
	width: 100%;
	background: var(--zeal-white);
	border-radius: 6px;
	border-top: 6px solid transparent;   /* the colour accent; reserved so default + accent cards align */
	padding: 1.05em 1.3em 1.25em;
	margin-bottom: 1em;
	color: #555;
	/* ⚠️ LIGHTER THAN IT WAS (Vince, 2026-08-18: "a little bit too much drop shadow on these sections in general
	   — that needs to be a global change"). Was 0 1px 5px rgba(0,0,0,0.09). A card's shadow only has to say
	   "this sits above the page"; stacked down a long form the heavier version read as every section shouting.
	   ⚠️ .zt-row-card:hover RESTATES this value — box-shadow does not cascade-merge, so if you change it here,
	   change it there too or a hovered row silently loses its lift. */
	box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.zt-action-card-title { display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.6em; font-size: 1.05rem; font-weight: 700; color: var(--zeal-dark-grey); }
.zt-action-card-title-icon { color: var(--zeal-tiffany); }
.zt-action-card-title-text { flex: 1 1 auto; min-width: 0; }
.zt-action-card-notes { margin-bottom: 0.6em; }
.zt-action-card-header { padding-bottom: 0.6em; margin-bottom: 0.75em; border-bottom: 1px solid #ececec; }
.zt-action-card-footer { padding-top: 0.7em; margin-top: 0.85em; border-top: 1px solid #ececec; }
.zt-action-card-body > :last-child { margin-bottom: 0; }

/* FILLED HEADER / FOOTER (FilledHeaderFooter) — the legacy action-card look: the card drops its own padding + top
   accent so the HEADER and FOOTER become full-bleed grey bands (edge to edge, clipped to the rounded corners),
   with the body padded between them. Each region carries its OWN padding, sharing one horizontal inset so their
   left edges line up. This is the first-class way to "fill" a header/footer — consumers never hack padding. */
.zt-action-card-flush { padding: 0; overflow: hidden; border-top: 0; }
.zt-action-card-flush .zt-action-card-header {
	background: var(--zt-plain-bg); border-bottom: 1px solid #e4e4e4; padding: 0.8rem 1.15rem; margin: 0;
}
.zt-action-card-flush .zt-action-card-body { padding: 0.85rem 1.15rem; }
.zt-action-card-flush .zt-action-card-footer {
	background: var(--zt-plain-bg); border-top: 1px solid #e4e4e4; padding: 0.7rem 1.15rem; margin: 0;
}

/* GREEN / GOLD-BANDED flush cards — the user SETTINGS page (profile, phones, addresses, passkeys). The HEADER band
   takes Zeal green (gold for archived rows) with WHITE content, matching the legacy UserDetails cards + the
   approved family cards; the footer stays a light band whose coloured buttons wrap on narrow screens. */
.zt-detail-flush.zt-action-card-green  .zt-action-card-header { background: var(--zeal-green-header);  border-bottom-color: rgba(0, 0, 0, 0.18); }
.zt-detail-flush.zt-action-card-gold   .zt-action-card-header { background: var(--zeal-gold);   border-bottom-color: rgba(0, 0, 0, 0.14); }
/* PURPLE = the CURRENT-device passkey (the legacy colours this device's passkey purple). Without this the purple
   card's header fell back to the grey default band + white text — the washed "grey-green" Vince saw. */
.zt-detail-flush.zt-action-card-purple .zt-action-card-header { background: var(--zeal-purple); border-bottom-color: rgba(0, 0, 0, 0.18); }
.zt-detail-flush .zt-action-card-header,
.zt-detail-flush .zt-action-card-header * { color: var(--zeal-white); }
.zt-detail-flush .zt-action-card-footer { background: var(--zt-plain-bg); display: flex; flex-wrap: wrap; gap: 0.4rem; }
/* TITLE rendered INSIDE a FilledHeaderFooter coloured band (ZtActionCard Title + FilledHeaderFooter) — a library
   control, not a hand-rolled header. Inside .zt-detail-flush the band is green/gold/purple with white text. */
.zt-action-card-band-title { display: flex; align-items: center; gap: 0.5em; min-width: 0; font-weight: 700; }
/* GROWS so any TitleActions beside it are pushed to the right edge of the band. */
.zt-action-card-band-title-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* RIGHT-ALIGNED TITLE-ROW ACTIONS (ZtActionCard.TitleActions) — a close (X), an "Add …". Resets the inherited
   title typography so a button renders at its own size rather than inheriting the heading's weight. */
.zt-action-card-title-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 400; }

/* EDIT-USER card header — identity lines (name / email / birthdate·age) come from ZtMediaObject Title + Lines, so
   they share ONE typography + rhythm (no bespoke per-line class). The Close is a round, translucent circle. */
/* ⚠️ TRANSLUCENT DARK, NOT TRANSLUCENT WHITE. This close button was drawn for the old SOLID dark-green band,
   where a white wash read as a soft circle. The edit-user card now wears the pale happy-green band like every
   other section, and rgba(255,255,255,.18) over a near-white surface is invisible — the button would still be
   there, just unfindable. A dark wash gives the same soft-circle effect on a light band. */
.zt-profile-close.ztbtn.ztbtn-icon { width: 34px; height: 34px; min-width: 34px; padding: 0; border-radius: 50%; background: rgba(0, 0, 0, 0.06); border-color: transparent; color: var(--zeal-dark-green); }
.zt-profile-close.ztbtn.ztbtn-icon:hover { background: rgba(0, 0, 0, 0.12); color: var(--zeal-dark-green); filter: none; }
.zt-profile-close .zt-fa { font-size: 1.15rem; }
/* vertical rhythm between stacked detail cards — also spaces the first passkey card off the "Enable" button. */
.zt-detail-row { margin-top: 0.9rem; }
.zt-passkey-enable { margin: 0.5rem 0 0.2rem; }

/* ── THE SETTINGS SECTIONS (passkeys · phones · addresses) ──────────────────────────────────────────────────
   EQUAL SPACE ABOVE THE FIRST ROW, BETWEEN EVERY PAIR, AND BELOW THE LAST. Vince has raised this on every
   section that stacks cards, and it is the same defect every time, so it gets the same cure the schedule,
   document and programs sections already use.

   WHY IT KEPT HAPPENING: the rows are plain block children of the card body and .ztdc carries its own
   margin-top/-bottom: 0.5em. So the space ABOVE the first row was body padding + that margin, the space
   BETWEEN two rows was the COLLAPSED margin alone (0.5em, not 1em), and the space BELOW the last was body
   padding only — because .zt-action-card-body > :last-child zeroes the final margin. Three different gaps.

   THE FIX: make the body a flex column so margins stop collapsing and ONE gap governs, then drop the rows'
   own vertical margins. --zt-stack-gap is deliberately the same figure as the flush body's padding, so top /
   between / bottom all land on the identical value. */
.zt-settings-section > .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-stack-gap); }
.zt-settings-section > .zt-action-card-body > .ztdc { margin-top: 0; margin-bottom: 0; }
/* The passkey section's lead-in sentence is a flex child too, so the gap under it matches the row gap. */
.zt-settings-section > .zt-action-card-body > .zt-page-content { margin: 0; }

/* ⚠️ FOUR VERBS, NOT ONE EDIT BUTTON — the same problem the document row solved. .ztdc pins .ztdc-actions
   ABSOLUTELY to the right and reserves 52px of padding for it, which fits a single Edit and nothing more;
   Edit / Up / Down / Archive would run off the card edge and sit on top of the facts. So on a settings row the
   actions rejoin the flex flow as their own wrapping group: still right, still after the facts, able to wrap. */
.zt-settings-row { padding-right: 16px; }
.zt-settings-row .ztdc-actions {
	position: static; transform: none;
	flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 0.4rem; max-width: 100%;
}

/* ── ROW-LEVEL BUSY + THE REORDER SLIDE ────────────────────────────────────────────────────────────────────
   A reorder changes exactly TWO rows, so only those two go busy and only those two animate. The section stays
   live and the rest of the list never flickers.

   THE VEIL, NOT A LOADER: a thin translucent wash + a small spinner. The row must stay READABLE while it is
   busy — the whole point is seeing which two rows are swapping — so this deliberately does not use the
   full-bleed LavaLoader that the page and section tiers use. */
.ztdc { position: relative; }
.ztdc-working { pointer-events: none; }
.ztdc-busy {
	position: absolute; inset: 0; z-index: 2;
	display: flex; align-items: center; justify-content: center;
	background: rgba(255, 255, 255, 0.55);
	border-radius: 8px;
	font-size: 1.4rem; color: var(--zeal-green);
}

/* THE SWAP ITSELF. When the two rows come back in their new order Blazor re-renders them in place, which is an
   INSTANT jump — the thing that makes a reorder feel like a page reload rather than a move. These two keyframes
   replay that jump as motion: each row starts offset by roughly one row-height in the direction it came FROM
   and slides to rest, so the pair reads as passing each other.

   ⚠️ A PERCENTAGE, NOT A PIXEL COUNT. Rows are not a fixed height (a phone row is three lines, an address row
   two, and both grow when they wrap on a narrow screen), so 100% of the row's OWN height is the only offset
   that is right at every size. It is approximate when the two rows differ in height, and that is fine — the
   eye reads direction and settling, not exact distance.

   Motion is opt-out for anyone who asked for less of it. */
@keyframes zt-row-slide-from-below { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes zt-row-slide-from-above { from { transform: translateY(-100%); } to { transform: translateY(0); } }
.zt-row-moved-up { animation: zt-row-slide-from-below 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
.zt-row-moved-down { animation: zt-row-slide-from-above 0.32s cubic-bezier(0.22, 0.61, 0.36, 1) both; }
/* The moving pair rides ABOVE its neighbours so the slide is never clipped by the row it passes. */
.zt-row-moved-up, .zt-row-moved-down { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
	.zt-row-moved-up, .zt-row-moved-down { animation: none; }
}

/* ONE COLUMN, THREE ROWS on a PHONE row (Vince, 2026-08-06): name, type, number. Three short independent facts
   that the shared 2-col grid split into name|type over a lone half-width number with a ragged gutter. The
   ADDRESS row deliberately keeps two columns — its four facts pair up (what it is / where it is). */
.zt-settings-phone .ztdc-details { grid-template-columns: minmax(0, 1fr); }

/* ══ 🥋 ZT BELT — AN ACTUAL BELT, DRAWN ════════════════════════════════════════════════════════════════════
   The three markings the control documents: the BODY (first colour), the ACCENT (a stripe through the middle,
   or alternating coral banding), and the TAPES on the TIP.

   ⚠️ EVERY COLOUR ARRIVES AS A CUSTOM PROPERTY (--zt-belt-body / -accent / -tip) set inline by the control,
   because they come out of a database row and no static class can express them. That is the one inline-style
   form the UI charter permits.

   SIZED IN em THROUGHOUT, so one control works as a 2.4rem row glyph and a 4rem card hero without a second
   set of rules — the same call .ztdc-icon already makes for its disc. */
.zt-belt {
	position: relative;
	display: inline-block;
	width: 3.4em;
	height: 1em;
	font-size: 1.5rem;
	border-radius: 0.14em;
	overflow: hidden;
	/* DROP SHADOW ONLY. The outline lives on ::after — see below for why that matters. */
	box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
	vertical-align: middle;
	flex: 0 0 auto;
}

/* ⚠️ THE OUTLINE IS DRAWN *INSIDE* THE BELT, AS AN OVERLAY — and this is a bug fix, not a preference.
   (Vince, 2026-08-18: "on the white belt, it looks like the black ranked sleeve is skinnier vertically than the
   belt itself.")

   It used to be an OUTER ring (`box-shadow: 0 0 0 1px`), which paints 1px ABOVE and BELOW the element while the
   sleeve — like the body — only fills the element itself. So the belt read as 2px taller than its own sleeve.
   Invisible at 0.28 alpha on a blue belt; glaring on a white one once the pale treatment pushed that ring to
   0.55. Inset here, the belt's visual bounds ARE the element's bounds, so body and sleeve are exactly equal.

   ⚠️ IT HAS TO BE ::after, NOT `box-shadow: inset` ON THE BELT. The body and the sleeve are absolutely
   positioned CHILDREN, and children paint above a parent's inset shadow — the ring would simply be covered. An
   overlay stacked above both is the only version that shows. */
.zt-belt::after {
	content: '';
	position: absolute; inset: 0;
	border-radius: inherit;
	box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.30);
	pointer-events: none;
	z-index: 3;
}
.zt-belt-sm { font-size: 1.1rem; }

.zt-belt-body {
	position: absolute; inset: 0;
	background: var(--zt-belt-body);
}

/* ② THE CENTRE STRIPE — a third of the belt's height, straight down the middle. */
.zt-belt-stripe {
	position: absolute; left: 0; right: 0; top: 33.5%; height: 33%;
	background: var(--zt-belt-accent);
}

/* ② (coral) ALTERNATING BANDS. A repeating-linear-gradient rather than N elements: the band count stays
   constant at every size, and there is no DOM cost that scales with it. */
/* THE BAND IS THREE TIMES WIDER THAN IT STARTED (0.34em -> 1.02em), and therefore a third as many of them
   (Vince, 2026-08-18: "we have too many alternating red and white, too many alternating red and black… the
   thickness of the red and black corals are much wider, probably three times wider").

   AT THE OLD WIDTH IT WAS READING AS A TEXTURE, NOT AS BANDING — roughly fifty bars across the belt, which at
   row size blurred into a pinstripe. A coral belt has a handful of wide alternating panels; the count is
   supposed to be countable.

   ONE VARIABLE DRIVES BOTH HALVES of the repeat, so the red and the black stay exactly equal however it is
   tuned — the period is simply twice the band. */
.zt-belt { --zt-belt-coral-band: 1.02em; }

.zt-belt-coral {
	position: absolute; inset: 0;
	background: repeating-linear-gradient(
		90deg,
		var(--zt-belt-accent) 0 var(--zt-belt-coral-band),
		transparent          var(--zt-belt-coral-band) calc(var(--zt-belt-coral-band) * 2));
}

/* ③ THE TIP, and the tapes taped across it. The tip sits at the RIGHT end, which is where it falls when a belt
   is tied and photographed — and it is what the degree tapes are wrapped around. */
.zt-belt-tip {
	position: absolute; top: 0; bottom: 0;
	/* ⚠️ THE SLEEVE IS NOT THE END OF THE BELT — a whisper of belt colour runs past it (Vince, 2026-08-18, with
	   a photo of a brown belt: "you should see the brown belt past the rank sleeve… not as much as you see here
	   in the picture, but just a whisper"). The sleeve is sewn ONTO the belt a little short of the tip; ending
	   the belt at the sleeve made it look like a two-tone bar rather than a belt with a sleeve on it. */
	right: var(--zt-belt-tip-inset);
	/* 3.75em — THREE TIMES the 1.25em it started at (Vince, 2026-08-18). On a real belt the black sleeve is a
	   substantial run of the visible length, not a cap on the end, and at the old width the degree tapes had
	   nowhere to sit. */
	width: calc(var(--zt-belt-unit) * 9);
	background: var(--zt-belt-tip);
	/* ⚠️ FIXED SLOTS, ANCHORED AT THE RIGHT END — NOT redistributed as the count changes.
	   Vince, 2026-08-18: "The first stripe is always from the end… an equal space to the second, an equal space
	   to the third… If only one stripe is there, we just see the one on the far right. If two are there, then we
	   see two starting from the far right."

	   THIS IS THE FIX FOR THE PREVIOUS ATTEMPT. `space-evenly` spread whatever stripes existed across the whole
	   sleeve, so a 1-stripe belt centred its single stripe and a 4-stripe belt sat them somewhere else entirely —
	   the slots MOVED with the count. Tape does not work that way: slot 1 is at the end of the sleeve and stays
	   there whether or not slots 2-4 are filled.

	   row-reverse + flex-start is what pins slot 1 to the right and grows leftward at a fixed pitch. */
	display: flex; flex-direction: row-reverse; justify-content: flex-start; align-items: stretch;
	gap: var(--zt-belt-tape-w);
	/* ⚠️ BLACK ON BOTH SIDES OF THE TAPE, never flush to the sleeve's edge ("there is black space on either side
	   of all of the stripes"). This padding is the RIGHT-hand margin; the left one falls out of the arithmetic
	   below, because the tape block is anchored right and is one unit short of the sleeve. */
	padding-right: var(--zt-belt-unit);
	/* A TAPE THAT OVERFLOWS THE SLEEVE IS CLIPPED, never allowed to spill onto the belt body — the count is
	   capped at 10 in the control, and beyond four the sleeve is simply full. */
	overflow: hidden;
}

/* ⚠️ EVERY MEASUREMENT ON THE SLEEVE IS ONE NINTH OF IT, AND THAT IS THE WHOLE DESIGN.
   Nine equal units across the sleeve: [black] [white black white black white black white] [black].
   That is 1 margin + 7 of alternating tape and gap + 1 margin — so at four stripes the sleeve reads as equal
   bands of black and white with black showing at BOTH ends, exactly as the belt in Vince's photo does. Derive
   them from one number and the tape, the gap and the two margins can never drift out of proportion; pick them
   separately and they will.

   The INSET is what leaves a whisper of belt colour past the sleeve. Deliberately much smaller than the photo,
   which Vince said had too much. */
.zt-belt {
	/* THE STRUCTURAL UNIT — sleeve length, professor bars and the sleeve's inner margins are all multiples of
	   this, and it never changes. */
	--zt-belt-unit: calc(3.75em / 9);
	/* THE TAPE UNIT — width AND gap. Defaults to the structural unit and is OVERRIDDEN INLINE by the control
	   when a belt carries more degrees than fit at full size: a ninth-degree red belt wears nine stripes in a
	   sleeve sized for six, so they get narrower rather than clipped. Vince, 2026-08-18: "the degrees are
	   smaller, obviously, to fit inside the rank sleeve." */
	--zt-belt-tape-w: var(--zt-belt-unit);
	--zt-belt-tip-inset: 0.3em;
}

.zt-belt-tape {
	width: var(--zt-belt-tape-w);
	background: var(--zeal-white);
	/* NEVER SHRINK. A tape that shrank would make two belts with different counts look like different WIDTHS of
	   tape rather than different NUMBERS of it, which is the one thing this has to communicate. */
	flex: 0 0 auto;
}

/* ══ 🎖️ PROFESSOR BARS ══════════════════════════════════════════════════════════════════════════════════════
   A bar at EACH END of the rank sleeve, with the degree tape between them (Vince, 2026-08-18: "once you reach
   the rank of professor, you get two professor bars, which are basically stripes that go at the beginning and
   the end of the red rank sleeve").

   ⚠️ ABSOLUTELY POSITIONED, NOT FLEX CHILDREN. They belong to the SLEEVE'S ENDS, not to the run of tape — pinned
   here they stay put whether the belt carries no degrees or six, which is the whole point of calling them bars
   rather than stripes. */
.zt-belt-prof {
	position: absolute; top: 0; bottom: 0;
	/* THE STRUCTURAL UNIT, NOT THE TAPE UNIT. A professor bar is the same width on every belt that has one —
	   it must NOT thin out just because the wearer collected more degrees. That is the whole distinction
	   between a bar and a stripe. */
	width: var(--zt-belt-unit);
	background: var(--zt-belt-prof);
	z-index: 2;
}
.zt-belt-prof-start { left: 0; }
.zt-belt-prof-end { right: 0; }

/* THE PROFESSOR SLEEVE IS LONGER, and it has to be: fifteen units rather than nine, because it carries two bars
   AND up to six degrees between them.
     [bar] [gap] [white black white black white black white black white black white] [gap] [bar]
        1 +   1 +                        11 (6 tape + 5 gaps)                        +  1 +   1  = 15
   Same unit as every other belt, so a degree stripe is the same width whoever is wearing it — only the sleeve
   grows. The padding is what stops the tape running underneath the bars. */
.zt-belt-tip-prof {
	width: calc(var(--zt-belt-unit) * 15);
	padding-right: calc(var(--zt-belt-unit) * 2);
	padding-left: calc(var(--zt-belt-unit) * 2);
}

/* THE ROW BELT — across the top of a rank card. Proportioned by eye against a real belt (Vince, 2026-08-18:
   "ten percent taller… fifty percent shorter… the black ranked sleeve needs to be about three times longer").

   HALF THE CARD, NOT ALL OF IT. Edge-to-edge read as a banner rather than as a belt; at 50% the black sleeve
   becomes a real proportion of what you see, which is what makes it look like the object instead of a bar
   chart. The tip is 3.75em (was 1.25em) and stays em-based, so it scales with the belt's own height rather
   than with the card — a belt on a wide monitor gets longer, not thicker-tipped.

   ⚠️ min-width KEEPS IT A BELT ON A NARROW CARD. Without it, 50% of a phone-width card leaves a sleeve wider
   than the belt it is attached to. */
.zt-belt-wide {
	display: block;
	width: 50%;
	min-width: 11rem;
	height: 1.5rem;
	font-size: 1.5rem;
}

@media (max-width: 599px) {
	/* ON A PHONE THE CARD IS ALREADY NARROW, so half of it is a stub. The belt takes the full width back and the
	   sleeve returns to a sane share of it — the same reasoning as the min-width above, one band further down. */
	.zt-belt-wide { width: 100%; min-width: 0; }
}

/* ⚠️ A PALE BELT NEEDS A REAL EDGE, NOT A HINT (Vince, 2026-08-18: "the white belt one looks a little bit odd
   compared to the blue belt purple belt, brown belt, black belt"). A white belt on a near-white card was drawing
   as an outline around nothing while every coloured belt read as a solid object — so the pale ones get a
   darker, fully-opaque border and a faint inner shade that gives the body some substance. Applied by a class the
   control sets from the SAME luminance test that picks the tip colour, so the two can never disagree. */
.zt-belt-pale { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.26); }
.zt-belt-pale::after { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.48); }
/* A WHISPER OF SHADING top and bottom so the body reads as a woven belt catching light rather than as a blank
   rectangle. Deliberately faint — this is the difference between "an object" and "a hole", not a texture. */
.zt-belt-pale .zt-belt-body {
	background-image: linear-gradient(180deg, rgba(0, 0, 0, 0.09) 0%, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0) 60%, rgba(0, 0, 0, 0.09) 100%);
}

/* THE BELT RUNS ACROSS THE TOP OF A RANK ROW, edge to edge. It is the first child of the details column and the
   column is single-track, so full width is simply 100% — no grid spanning needed. The margin under it is what
   separates a picture from the facts about it. */
.zt-rank-belt-bar { width: 100%; min-width: 0; margin: 0 0 0.55rem; }

/* NO LEADING DISC ON A RANK ROW, at any width. The belt IS the identity of the rank, so a medal disc beside it
   is a second, worse icon competing with the real one. (.zt-settings-row already drops the disc below 600px;
   this drops it everywhere, for a different reason.) */
.zt-rank-row .ztdc-icon { display: none; }

/* ── THE MEMBER'S BELT ON THE USER-HOME PROGRAM CARD ────────────────────────────────────────────────────────
   The belt and the rank it names travel as ONE grid cell (Vince, 2026-08-18) — the belt on top, "Rank: …"
   underneath — so the two-column grid can never split them across a row boundary the way two loose items would.
   Points and Status keep the right-hand column exactly as before. */
.zt-prog-rank-cell { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }

/* THE LAST COLUMN — status on top, points underneath. Same construction as the rank cell so the two columns
   sit on the same baseline and stack identically when the card narrows. */
.zt-prog-status-cell { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }

/* THE BELT IS EXACTLY AS WIDE AS THE RANK LINE UNDER IT, and there is no magic number involved — that is the
   point (Vince, 2026-08-18: "it should be, like, however much space before the text below it would start to go
   into ellipses and truncate").

   ⛔ NO max-width. .ztdc-details is `repeat(2, minmax(0, 1fr))` — two EQUAL columns — so the width the rank text
   has before it wraps IS the column width, and `width: 100%` matches it by construction at every screen size. A
   15rem cap used to sit here and it is what made the belt visibly shorter than the words beneath it; any fixed
   ceiling would be wrong again the moment the card changed width. */
.zt-prog-belt { width: 100%; min-width: 0; }

/* ⚠️ DROP THE ROW BELT'S min-width HERE. .zt-belt-wide carries an 11rem floor so a belt never collapses to a
   stub on a narrow rank row — but this cell is one half of a two-column grid inside a card, and a floor it
   cannot honour would push the belt straight out through the card's edge. In here it simply fills the cell. */
.zt-prog-belt .zt-belt-wide { width: 100%; min-width: 0; }

/* ── RANK GROUP ROW (admin) ────────────────────────────────────────────────────────────────────────────────
   The bracket vocabulary. Single-column details for the same reason the rank row takes one: a name, a sentence
   of description and a usage count are independent lines of very different lengths. */
.zt-rank-group-row .ztdc-details { grid-template-columns: minmax(0, 1fr); }

/* THE SWATCH IS A ZtBelt WITH NO SLEEVE — a solid belt IS a colour chip, so the belt control draws it rather
   than a second one-off swatch element existing to do the same job slightly differently. Capped short so it
   reads as a chip and not as a belt somebody could mistake for a rank. */
.zt-rank-group-swatch { width: 100%; max-width: 5rem; min-width: 0; margin: 0 0 0.35rem; }
.zt-rank-group-swatch .zt-belt { width: 100%; }

/* THE GROUP EDITOR'S LIVE COLOUR — "#6A2C91" is not a value anyone can check by reading, so it is painted as it
   is typed. Same construction as the wave's other full-width blocks. */
.zt-rank-group-preview {
	display: flex; align-items: center; justify-content: center;
	gap: 0.9rem; flex-wrap: wrap;
	padding: 0.9rem 1rem;
	border-radius: 10px;
	background: var(--zt-plain-bg);
	border: 1px solid var(--zeal-silver);
}
.zt-rank-group-preview-label { font-weight: 600; min-width: 0; }

/* ── PROGRAM RANK ROW ──────────────────────────────────────────────────────────────────────────────────────
   ONE COLUMN, for the same reason the phone row takes one: a rank's three facts — its name, what it means, and
   how many classes it takes — are independent lines of very different lengths, and the shared 2-col grid put a
   128-character belt name beside a 512-character description and tore both into ragged strips. */
.zt-rank-row .ztdc-details { grid-template-columns: minmax(0, 1fr); }

/* THE RUNG NUMBER IS THE TITLE, so it is drawn as a number rather than as a word. A belt system is read as a
   SEQUENCE — "the fourth rung" means something, an id does not — and at a glance down a ladder of twenty belts
   the position is what the eye is actually tracking. Tabular figures keep the column dead straight from 1 to 20
   to 100 (proportional digits make a '1' narrow and the list wobble).

   ⚠️ NO FIXED px WIDTH. The number is centred in a min-width box measured in ch, so it holds its column at any
   root font size and still grows for a three-digit ladder instead of clipping. */
.zt-rank-row .ztdc-main { flex: 0 0 auto; min-width: 0; }
.zt-rank-row .ztdc-title {
	min-width: 2.5ch;
	text-align: center;
	font-size: 1.35rem;
	font-variant-numeric: tabular-nums;
	line-height: 1.1;
}
/* The ARCHIVED word under it must not stretch the number's column — it wraps under its own width instead. */
.zt-rank-row .ztdc-subtitle { text-align: center; white-space: nowrap; }

@media (max-width: 599px) {
	/* THE RUNG NUMBER STAYS ON A PHONE, unlike the leading icon that .zt-settings-row drops. It is not
	   decoration — it is the row's position in the ladder, which is the one fact a reorder screen exists to
	   show. It just stops being its own column: it sits inline at the top of the stacked card at text size. */
	.zt-rank-row .ztdc-title { text-align: left; font-size: 1.15rem; min-width: 0; }
	.zt-rank-row .ztdc-subtitle { text-align: left; }
}

@media (max-width: 599px) {
	/* The buttons already wrap; give them the full row width so they don't squeeze beside the facts, and keep
	   them left-aligned with everything else once the card is a single column. */
	.zt-settings-row .ztdc-actions { flex: 1 1 100%; justify-content: flex-start; }

	/* ⚠️ NO LEADING ICON ON A PHONE (Vince, 2026-08-06). On a wide row the circled glyph is the anchor your eye
	   starts from — it sits BESIDE the facts and costs nothing. Once .ztdc wraps to a single column it stops
	   being an anchor and becomes a full-width row of its own holding one small circle and a lot of empty space
	   above the content, which is the "just blank" band in the 375px screenshot. The facts are what matter on a
	   phone, so the icon goes and they start at the top of the card. */
	.zt-settings-row .ztdc-icon { display: none; }
}

/* colour accents — top border + matching title-icon hue */
.zt-action-card-green   { border-top-color: var(--zeal-green); }
.zt-action-card-green   .zt-action-card-title-icon { color: var(--zeal-green); }
.zt-action-card-red     { border-top-color: var(--zeal-red); }
.zt-action-card-red     .zt-action-card-title-icon { color: var(--zeal-red); }
.zt-action-card-tiffany { border-top-color: var(--zeal-tiffany); }
.zt-action-card-tiffany .zt-action-card-title-icon { color: var(--zeal-tiffany); }
.zt-action-card-gold    { border-top-color: var(--zeal-gold); }
.zt-action-card-gold    .zt-action-card-title-icon { color: var(--zeal-gold); }
.zt-action-card-blue    { border-top-color: var(--zeal-blue); }
.zt-action-card-blue    .zt-action-card-title-icon { color: var(--zeal-blue); }
.zt-action-card-purple  { border-top-color: var(--zeal-purple); }
.zt-action-card-purple  .zt-action-card-title-icon { color: var(--zeal-purple); }
.zt-action-card-orange  { border-top-color: var(--zeal-orange); }
.zt-action-card-orange  .zt-action-card-title-icon { color: var(--zeal-orange); }

/* ---- CHART (from-scratch SVG, no library) ----------------------------- */
.zt-chart { width: 100%; }
.zt-chart-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px 14px; margin-bottom: 0.5em; }
.zt-chart-title { font-weight: 700; color: var(--zeal-dark-grey); font-size: 1rem; }
.zt-chart-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-left: auto; }
.zt-chart-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8rem; color: #555; }
.zt-chart-legend-swatch { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; }
.zt-chart-svg { display: block; width: 100%; height: auto; overflow: visible; }
.zt-chart-grid { stroke: #ececec; stroke-width: 1; }
.zt-chart-axis { stroke: #cfcfcf; stroke-width: 1; }
.zt-chart-axis-label { fill: #999; font-size: 11px; font-family: var(--zt-font, "Inter", system-ui, sans-serif); }
.zt-chart-dot { stroke: #fff; stroke-width: 1.2; cursor: pointer; transition: r 0.1s ease; }
.zt-chart-dot:hover { r: 5; }
.zt-chart-bar { cursor: pointer; transition: opacity 0.1s ease; }
.zt-chart-bar:hover { opacity: 0.82; }
/* PIE slices — white seams between wedges, same hover dim as bars; the on-slice percentage is white and bold
   so it reads on every slice colour. */
.zt-chart-slice { stroke: #fff; stroke-width: 1.5; cursor: pointer; transition: opacity 0.1s ease; }
.zt-chart-slice:hover { opacity: 0.85; }
.zt-chart-pie-label { fill: #fff; font-size: 13px; font-weight: 700; font-family: var(--zt-font, "Inter", system-ui, sans-serif); pointer-events: none; }
.zt-chart-empty { display: flex; align-items: center; justify-content: center; color: #aaa; font-style: italic; font-size: 0.9rem; border: 1px dashed #e3e3e3; border-radius: 6px; }

/* ---- ACTION-CARD CHART (chart + time-range buttons) ------------------- */
/* Compact range pills so all six fit one row down to iPhone SE (375px). */
.zt-acc-ranges { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
.zt-acc-ranges .ztbtn.ztbtn-icon { height: 27px; min-width: 0; padding: 0 0.6rem; }
.zt-acc-ranges .ztbtn .ztbtn-text { font-size: 0.78rem; }
/* THE RANGE BUTTONS SIT IN EQUAL AIR — the same gap above them (chart → buttons) as below them
   (buttons → content). Keep this value identical to .zt-acc-ranges' margin-top; they are one visual pair, and
   any difference reads as the row being nudged toward whichever side is tighter. */
.zt-acc-additional { margin-top: 10px; }

/* ---- SIGNATURE PAD ---------------------------------------------------- */
/* Full width (like an action card) + no big left field-icon (the header keeps its icon). */
.zt-input-container:has(.zt-signature) { max-width: 100%; }
.zt-input-container:has(.zt-signature) .zt-input-field-icon { display: none; }
.zt-signature { position: relative; max-width: 100%; }
.zt-signature-canvas {
	display: block;
	width: 100%;
	background: #fff;
	border: 1px solid var(--zeal-silver);
	border-radius: 6px;
	box-shadow: inset 0 0 0 1px #fff, inset 0 -1px 0 #f0f0f0;
	touch-action: none;       /* don't scroll the page while signing */
	cursor: crosshair;
}
.zt-signature-clear {
	position: absolute;
	top: 7px;
	right: 7px;
	display: inline-flex;
	align-items: center;
	gap: 5px;
	border: 1px solid #eee;
	background: rgba(255, 255, 255, 0.9);
	color: #999;
	font-size: 0.76rem;
	padding: 3px 8px;
	border-radius: 5px;
	cursor: pointer;
	transition: color 0.12s ease, border-color 0.12s ease;
}
.zt-signature-clear:hover { color: var(--zeal-red); border-color: #f0c2c2; }

/* ---- DOCUMENT INPUT (pick one PDF) ------------------------------------ */
/* Sibling of the FILE INPUT below, not a variant of it: that one crops images, this one takes a document whole.
   Full width like the other fields, and LEFT aligned — the image picker centres because a crop frame is the
   subject, whereas this is a row of text that should line up with every other field on the form. */
.zt-input-container:has(.zt-docinput) { max-width: 100%; }
.zt-input-container:has(.zt-docinput) .zt-input-field-icon { display: none; }
/* ONE GAP GOVERNS THE WHOLE CONTROL. Every child (description, file row, note, actions) is spaced by this single
   flex gap and carries NO margin of its own — that is what makes the rhythm identical in every section. The
   earlier version let each child bring its own margin, so the stack grew unevenly depending on which parts were
   present (a receipt with a note sat very differently from an empty slot). */
.zt-docinput { position: relative; max-width: 100%; display: flex; flex-direction: column; gap: 0.5rem; }
.zt-docinput > * { margin: 0 !important; }
/* The legacy's visible explanatory copy for each evidence slot — body text, not a help popover. */
.zt-docinput-desc { font-size: 0.86rem; line-height: 1.4; color: var(--zeal-grey); }
/* UNLABELLED MODE. The legacy's file inputs had no label — the SECTION heading named them, and repeating it in a
   grey bar above every picker is the duplication that made these sections read so tall. Without ZtFieldShell the
   control supplies the field rhythm itself, matching .zt-input-container exactly so it lines up with the
   labelled inputs beside it. */
.zt-docinput-bare { margin: 0.75em 0 0.5em; width: 100%; }
/* THE ATTACHED FILE. min-width:0 on the flex child is what lets a long name ellipsis instead of forcing the
   whole card to scroll sideways on a phone. */
.zt-docinput-file { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.75rem; border: 1px solid var(--zeal-silver); border-radius: 8px; background: #f7f9fa; min-width: 0; }
.zt-docinput-file-icon { flex: 0 0 auto; font-size: 1.25rem; }
.zt-docinput-file-meta { display: flex; flex-direction: column; min-width: 0; }
.zt-docinput-file-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-docinput-file-size { font-size: 0.8rem; color: #7a7a7a; }
/* WRAPS on a narrow screen so Choose and Remove stack rather than overflow. */
.zt-docinput-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
/* The <label> IS the button — clicking it activates the input it wraps, so no JS is needed to open the dialog. */
.zt-docinput-pick { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.5rem 0.9rem; border: 1px solid var(--zeal-silver); border-radius: 8px; background: #fff; cursor: pointer; font-weight: 600; min-height: 44px; }
.zt-docinput-pick:hover { background: #f2f4f5; }
.zt-docinput-pick-disabled { opacity: 0.55; cursor: default; pointer-events: none; }
/* HIDDEN WITHOUT display:none — a display:none input is unreachable by keyboard and invisible to screen readers,
   and the label's own click still has to land on something that exists. */
.zt-docinput-native { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }

@media (max-width: 599px) {
	/* Full-width tap targets on a phone — a 44px pill floating mid-row is easy to miss. */
	.zt-docinput-actions { flex-direction: column; align-items: stretch; }
	.zt-docinput-pick { justify-content: center; }
}

/* ---- FILE INPUT (image pick + pan/zoom/crop) -------------------------- */
/* No big left field-icon (the header keeps its icon). */
.zt-input-container:has(.zt-fileinput) { max-width: 100%; }
.zt-input-container:has(.zt-fileinput) .zt-input-field-icon { display: none; }
/* Centre the preview frame + controls within the (full-width) control. */
.zt-fileinput { position: relative; max-width: 100%; display: flex; flex-direction: column; align-items: center; text-align: center; }
/* target frame size under the placeholder (e.g. "1500 × 300") */
.zt-fileinput-empty-dims { display: block; margin-top: 0.3rem; font-size: 0.8rem; font-weight: 600; color: #b3b3b3; }
/* BUSY overlay while reading/downscaling a picked photo or uploading — covers the whole control. */
.zt-fileinput-busy { position: absolute; inset: 0; z-index: 6; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.8); border-radius: 12px; }
/* …EXCEPT IN EDITOR MODE, WHERE THE CONTROL *IS* THE SCREEN. The photo editor is a full-screen ZtModal, so a
   loader that stops at the crop frame leaves the header and the surrounding sheet live and clickable while the
   OS file dialog is opening — it reads as "part of the page is busy" when the truth is "the whole thing is"
   (Vince, 2026-08-15: "that needs to be over the entire screen, not just the control").

   FIXED, NOT ABSOLUTE, AND THAT IS WHAT MAKES IT WORK FROM IN HERE. The dialog is in the TOP LAYER, which no
   z-index can climb over from outside — the shell's own #zt-page-busy paints UNDERNEATH it. But a fixed
   descendant *of the dialog* is positioned against the viewport AND painted inside the dialog's stacking
   context, so it covers the whole screen, header included, from the one place that can. Overflow on
   .zt-modal-content / .zt-modal-body does not clip it (fixed escapes overflow clipping). */
.zt-fileinput-busy-full { position: fixed; inset: 0; z-index: 20; border-radius: 0; background: rgba(255, 255, 255, 0.92); }
.zt-fileinput-zoom, .zt-fileinput-actions { justify-content: center; }
.zt-fileinput-native { display: none; }
.zt-fileinput-frame {
	position: relative;
	overflow: hidden;
	border-radius: 8px;
	background: #f2f4f5;
	border: 1px solid var(--zeal-silver);
	touch-action: none;
	cursor: grab;
	user-select: none;
}
.zt-fileinput-frame:active { cursor: grabbing; }
.zt-fileinput-round { border-radius: 50%; }
.zt-fileinput-img { position: absolute; top: 0; left: 0; will-change: transform; pointer-events: none; }
.zt-fileinput-img-fit { position: static; width: 100%; height: 100%; object-fit: cover; }
.zt-fileinput-video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
.zt-fileinput-result { cursor: default; }
/* LOADING STATE — the LavaLoader (ZtBusy) fills the frame while the host fetches the current image; the
   frame's overflow:hidden + round mask clip it to the exact shape the photo will occupy. */
.zt-fileinput-loading { cursor: default; }

.zt-fileinput-zoom { display: flex; align-items: center; gap: 10px; margin: 10px 0 4px; max-width: 260px; color: #999; }
.zt-fileinput-zoom input[type="range"] { flex: 1 1 auto; accent-color: var(--zeal-tiffany); }

.zt-fileinput-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
/* PRIMARY row (Cancel/Save) inside the editor control — a touch more separation from the crop tools. */
.zt-fileinput-primary { margin-top: 14px; }

.zt-fileinput-empty {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 9px;
	border: 2px dashed #cfd6d8;
	border-radius: 8px;
	background: #fafbfb;
	color: #9aa3a6;
	font-size: 0.85rem;
	cursor: pointer;
	transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.zt-fileinput-empty:hover { border-color: var(--zeal-tiffany); color: var(--zeal-tiffany); background: #f3fbfb; }
.zt-fileinput-empty.zt-fileinput-round { border-radius: 50%; }
.zt-fileinput-empty-icon { font-size: 1.9rem; }

.zt-fileinput-error { display: flex; align-items: center; gap: 7px; margin-top: 9px; color: var(--zeal-red); font-size: 0.82rem; }

/* ======================================================================== */
/* NAVBAR — the ONE nav for the Zeal platform + connected sites.            */
/* Bottom-fixed chrome: hamburger · brand · identity trio · context bar ·   */
/* fullscreen, plus a slide-up nav-menu drawer.                             */
/* ======================================================================== */
.zt-navbar {
	position: fixed; bottom: 0; left: 0; right: 0; z-index: 9000;
	display: flex; align-items: center; gap: 14px;
	height: 60px; padding: 8px 16px; box-sizing: border-box;
	background: var(--zeal-dark-grey, #2b2b2b);
	border-top: 1px solid #444; color: #fff;
}
.zt-navbar-icon-btn {
	flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
	width: 38px; height: 38px; border: none; background: transparent; color: #fff;
	cursor: pointer; font-size: 1.25rem; border-radius: 8px;
	transition: color 0.15s ease, background 0.15s ease;
}
.zt-navbar-icon-btn:hover { color: var(--zeal-gold); background: rgba(255,255,255,0.07); }
.zt-navbar-menu { font-size: 1.4rem; }
.zt-navbar-fullscreen { margin-left: auto; }

.zt-navbar-brand { flex: 0 0 auto; display: inline-flex; align-items: center; }
.zt-navbar-brand img { height: 34px; display: block; }

/* ---- identity trio (context icons) — active slides to the right-most slot ---- */
.zt-navbar-identities {
	--zt-id: 38px; --zt-id-gap: 6px; --zt-slot: calc(var(--zt-id) + var(--zt-id-gap));
	position: relative; flex: 0 0 auto; height: var(--zt-id);
}
.zt-navbar-identities.items-1 { width: var(--zt-id); }
.zt-navbar-identities.items-2 { width: calc(2 * var(--zt-slot) - var(--zt-id-gap)); }
.zt-navbar-identities.items-3 { width: calc(3 * var(--zt-slot) - var(--zt-id-gap)); }
/* A CHIP IS AN <a> WHEN IT NAVIGATES AND A <button> WHEN YOU ARE ALREADY THERE (see ZtNavBar) — so this rule
   has to dress both. text-decoration is the only thing an anchor brings that a button did not. */
.zt-navbar-identity {
	position: absolute; top: 0; height: var(--zt-id);
	display: inline-flex; align-items: center; gap: 8px; padding: 0;
	border: none; background: transparent; color: #fff; cursor: pointer;
	text-decoration: none;
	transition: left 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.zt-navbar-identity.slot-0 { left: 0; }
.zt-navbar-identity.slot-1 { left: var(--zt-slot); }
.zt-navbar-identity.slot-2 { left: calc(2 * var(--zt-slot)); }
/* REORDER: the active identity is promoted to the right-most slot, others shift left. */
.zt-navbar-identities.items-2.active-0 .slot-0 { left: var(--zt-slot); }
.zt-navbar-identities.items-2.active-0 .slot-1 { left: 0; }
/* HOMES: user=slot0(left), account=slot1(middle), member=slot2(right). The active chip swaps into the right
   display spot with whatever's there; when USER is active it swaps with the MEMBER (so account stays middle,
   member drops to left) — matching the legacy arrangement. */
.zt-navbar-identities.items-3.active-0 .slot-0 { left: calc(2 * var(--zt-slot)); }
.zt-navbar-identities.items-3.active-0 .slot-1 { left: var(--zt-slot); }
.zt-navbar-identities.items-3.active-0 .slot-2 { left: 0; }
.zt-navbar-identities.items-3.active-1 .slot-0 { left: 0; }
.zt-navbar-identities.items-3.active-1 .slot-1 { left: calc(2 * var(--zt-slot)); }
.zt-navbar-identities.items-3.active-1 .slot-2 { left: var(--zt-slot); }
/* active-2 / active-1(items-2) already right-most — no override needed. */
.zt-navbar-identity.is-active { z-index: 2; }

.zt-navbar-avatar {
	flex: 0 0 auto; width: var(--zt-id); height: var(--zt-id); border-radius: 50%;
	overflow: hidden; display: inline-flex; align-items: center; justify-content: center;
	background: #444; color: #bbb; font-size: 1.05rem; border: 2px solid transparent;
	transition: border-color 0.15s ease;
}
.zt-navbar-avatar img { width: 100%; height: 100%; object-fit: cover; }
/* THE ICON FALLBACK SHOWS BY DEFAULT; A SUCCESSFULLY-LOADED <img> HIDES IT. A FAILED IMAGE (onerror adds
   .img-failed) HIDES THE BROKEN <img> AND REVEALS THE ICON AGAIN. */
.zt-navbar-avatar-fallback { display: inline-flex; align-items: center; justify-content: center; }
.zt-navbar-avatar:has(img):not(.img-failed) .zt-navbar-avatar-fallback { display: none; }
.zt-navbar-avatar.img-failed img { display: none; }
.zt-navbar-identity-details {
	flex: 0 1 auto; min-width: 0;
	display: flex; flex-direction: column; justify-content: center;
	font-size: 8.5pt; line-height: 1.2; color: #fff; text-align: left;
	white-space: nowrap;
}
.zt-navbar-identity-details > span { width: 135px; overflow: hidden; text-overflow: ellipsis; }
.zt-navbar-identity-name { font-weight: 600; color: #fff; }

/* ---- context navbar (the active context's buttons; overflow-fit) ---- */
.zt-navbar-context {
	flex: 1 1 auto; min-width: 0; overflow: hidden;
	display: flex; align-items: center; gap: 6px;
}
/* PROD-MATCHED: VERTICAL TILE — ICON STACKED OVER A TRUNCATED LABEL. */
.zt-navbar-ctx-btn {
	flex: 0 0 auto;
	display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
	width: 55px; max-width: 55px; height: 50px; padding: 5px 10px;
	border: none; border-radius: 3px; background: rgba(255, 255, 255, 0.20);
	color: #fff; cursor: pointer;
	transition: color 0.15s ease, background 0.15s ease;
}
.zt-navbar-ctx-btn .zt-fa { font-size: 13pt; transition: color 0.15s ease, transform 0.15s ease; }
.zt-navbar-ctx-label { display: block; max-width: 100%; font-size: 6.5pt; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-navbar-ctx-btn:hover { color: var(--zeal-gold); }
.zt-navbar-ctx-btn:hover .zt-fa { color: var(--zeal-gold); transform: scale(1.2); }
.zt-navbar-ctx-btn.is-active { background: #aaaaaa; color: #111111; }
.zt-navbar-ctx-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.zt-ctx-hidden { display: none !important; }
@media (max-width: 839px) {
	.zt-navbar-ctx-btn { width: 48px; max-width: 48px; }
	.zt-navbar-ctx-btn .zt-fa { font-size: 11pt; }
}

/* ---- slide-up nav-menu drawer (the hamburger) ---- */
.zt-navmenu {
	position: fixed; left: 0; right: 0; top: 0; bottom: 60px; z-index: 8999;
	background: rgba(18, 18, 18, 0.94);
	backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
	overflow: auto; opacity: 0; transform: scaleY(0); transform-origin: bottom center;
	pointer-events: none; visibility: hidden;
	transition: transform 0.34s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.26s ease, visibility 0s linear 0.34s;
}
.zt-navmenu.open { opacity: 1; transform: scaleY(1); pointer-events: auto; visibility: visible; transition-delay: 0s; }
.zt-navmenu-inner {
	display: flex; flex-wrap: wrap; align-items: flex-start; align-content: flex-start;
	gap: 35px; padding: 32px 48px;
}
/* LEFT-ALIGNED GROUPS (the original prod layout): title sits at the left of its column, the tile
   grid flows left underneath it. */
.zt-navgroup { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.zt-navgroup-title {
	display: flex; align-items: center; justify-content: flex-start; gap: 4px;
	color: #fff; font-weight: 400; font-size: 10pt;
}
.zt-navgroup-items {
	display: grid; gap: 10px;
	grid-template-columns: repeat(var(--zt-nav-cols, 2), 68px);
}
/* PROD-MATCHED: 68px VERTICAL TILE — ICON OVER LABEL — SAME VOCABULARY AS THE CONTEXT BAR. */
.zt-navmenu-btn {
	display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
	width: 68px; height: 68px; padding: 0;
	border: none; border-radius: 3px; background: rgba(255, 255, 255, 0.20);
	opacity: 0.9; color: #fff; cursor: pointer;
	transition: all 0.2s ease;
}
.zt-navmenu-btn .zt-fa { font-size: 14pt; transition: color 0.15s ease, transform 0.15s ease; }
.zt-navmenu-btn span { max-width: 60px; font-size: 7pt; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-navmenu-btn:hover { color: var(--zeal-gold); opacity: 1; }
.zt-navmenu-btn:hover .zt-fa { color: var(--zeal-gold); transform: scale(1.2); }
.zt-navmenu-btn.is-active { background: #aaaaaa; color: #111111; }
.zt-navmenu-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.zt-navmenu-footer {
	flex: 1 1 100%; margin-top: 8px; padding-top: 16px;
	border-top: 1px solid rgba(255,255,255,0.12);
	display: flex; gap: 10px; flex-wrap: wrap;
}

@media (max-width: 839px) { .zt-navbar-brand { display: none; } }
/* HIDE THE ACTIVE-IDENTITY DETAILS WHERE THE CONTEXT BAR NEEDS THE ROOM. */
@media (max-width: 839px) { .zt-navbar-identity-details { display: none; } }

/* TWO-ROW LAYOUT ON PHONES: the context tiles get their OWN full-width row on top,
   the main chrome (hamburger · trio · fullscreen) sits on the row below. Lets a full set
   of context tiles show instead of all but one spilling into the menu. */
@media (max-width: 599px) {
	.zt-navbar {
		flex-wrap: wrap;
		height: auto;
		padding: 6px 8px;
		column-gap: 8px;
		row-gap: 4px;
	}
	/* TOP ROW — the context bar, full width. Left-aligned with a tight gap so the tiles pack toward
	   the left; the FIRST tile's left edge stays lined up with the hamburger below. */
	.zt-navbar-context {
		order: -1;
		flex: 0 0 100%;
		width: 100%;
		justify-content: flex-start;
		gap: 6px;
		padding-bottom: 6px;
		border-bottom: 1px solid rgba(255, 255, 255, 0.10);
	}
	/* SMALLER TILES SO A FULL SET OF 7 FITS ACROSS A PHONE. */
	.zt-navbar-ctx-btn { width: 42px; max-width: 42px; height: 44px; padding: 4px 2px; }
	.zt-navbar-ctx-btn .zt-fa { font-size: 12pt; }
	/* BOTTOM ROW NOW HAS ROOM — SHOW THE ACTIVE IDENTITY'S 3 LINES NEXT TO THE TRIO AGAIN. */
	.zt-navbar-identities { --zt-id: 36px; }
	/* DETAILS FLEX-SHRINK WITH A ZERO BASIS so the bottom row NEVER wraps a 3rd row on ultra-narrow
	   screens (e.g. Z-Fold cover ~344px): the details absorb the leftover width, grow to push the
	   fullscreen button to the right edge, and truncate when space runs out. */
	.zt-navbar-identity-details { display: flex; flex: 1 1 0; min-width: 0; font-size: 7.5pt; }
	.zt-navbar-identity-details > span { width: auto; max-width: 100%; }
	/* DETAILS NOW OWNS THE SLACK — DROP THE AUTO MARGIN OR IT WOULD STARVE THE DETAILS TO 0 WIDTH. */
	.zt-navbar-fullscreen { margin-left: 0; }
}
/* NAV MENU (drawer) ON PHONES — every group goes 4 tiles ACROSS with EQUAL fixed-width (68px) columns, and the
   groups are centered. Faithful to the legacy Blazor ZtNavGroup (repeat(4, max-content) over fixed 68px
   .navbutton tiles). Reduced side padding so the 4-wide grid fits + centers on a phone. */
@media (max-width: 599px) {
	.zt-navmenu-inner { justify-content: center; padding: 32px 20px; gap: 24px; }
	.zt-navgroup-items { grid-template-columns: repeat(4, 68px); }
}
@media (prefers-reduced-motion: reduce) {
	.zt-navbar-identity, .zt-navmenu { transition: none; }
}

/* ======================================================================== */
/* APP SHELL + LOGIN — the shared authenticated app (Zeal.Maui.App.Components) */
/* ======================================================================== */
.zt-app { min-height: 100%; }
.zt-app-main { display: block; }

/* ⚠️ ONE FACE FOR THE WHOLE APP (Vince, 2026-08-06 — "how do I get a crisper cleaner font?").
   Card titles, section bands and content rows inherited the HOST's body font (Montserrat, fetched from Google
   Fonts) while every Zt control declared --zt-font — TWO typefaces on the same page, which is half of why it
   read as "fuzzy and weird"; the faux-bold Gotham was the other half (see the @font-face note at the top).
   The authenticated app column now states the UI face once, for everything inside it. The public marketing
   pages keep the body font they were designed with. */
.zt-shell-content { font-family: var(--zt-font); }
.zt-app-shell {
	max-width: 599px; margin: 0 auto; padding: 28px 24px;
	font-family: var(--zt-font); color: #2c2c2c;
	/* OWN CARD — authenticated pages render in a transparent shell column, so each content block supplies its
	   own white card over the animated backdrop (consistent with the ZtPageSection cards on the user home). */
	background: #ffffff; border-radius: 6px; box-shadow: 0 10px 40px rgba(0, 0, 0, .25);
}
@media (max-width: 599px) {
	.zt-app-shell { max-width: 100%; border-radius: 0; box-shadow: none; }
}
.zt-app-shell h1 {
	display: flex; align-items: center; gap: 10px;
	font-size: 1.6rem; color: #1c1c1c; margin: 0 0 0.4em;
}
.zt-app-shell h1 .zt-fa { color: var(--zeal-tiffany); }
.zt-app-shell-sub { color: #555; line-height: 1.5; margin: 0 0 1.2em; }
.zt-app-shell-state {
	display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px;
	margin: 0 0 1.4em; padding: 16px; background: #f5f7f7; border-radius: 10px; font-size: 0.92rem;
}
.zt-app-shell-state dt { font-weight: 700; color: #555; }
.zt-app-shell-state dd { margin: 0; color: #1a1a1a; font-variant-numeric: tabular-nums; }

.zt-login { max-width: 599px; }
/* Card heading + the stacked "Other Ways to Login" option buttons (legacy: column, ~5px gap). */
.zt-login-heading { display: flex; align-items: center; gap: 8px; font-size: 1.05rem; font-weight: 700; color: #1c1c1c; margin: 0 0 0.6em; }
.zt-login-heading .zt-fa { color: var(--zeal-tiffany); }
.zt-login-ways { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; margin: 0.4em 0 0; }
.zt-login-logo { display: block; height: 48px; width: auto; margin: 0 0 1.2em; }
.zt-login-intro { font-size: 9pt; line-height: 1.5; color: #555; margin: 0 0 1.2em; }
/* INSIDE A WAVE BODY THE GAP OWNS THE SPACING, SO THE PARAGRAPH OWNS NONE. .zt-wave-body is a flex column with
   --zt-wave-gap between children; a child that ALSO carries margin-bottom gets gap+margin below it and only gap
   above, which is exactly the uneven "space above and below that paragraph" Vince keeps catching. Same rule the
   body already applies to every form control — one rhythm, no per-form hacks. */
.zt-wave-body .zt-login-intro { margin: 0; }
/* THE "HAVING TROUBLE?" LINE NOW LIVES IN A WAVE BODY, AND THE WAVE BODY OWNS THE RHYTHM. Both .zt-wave-body
   and the form inside it are flex columns with --zt-wave-gap between children, so a child that ALSO carries
   margin-top gets gap + margin above it and reads as a hole in the stack. Its margin was written for the old
   bare white card, where nothing else supplied any spacing at all. Same fix .zt-login-intro carries above. */
.zt-wave-body .zt-login-trouble { margin-top: 0; }
.zt-login-trouble { margin: 1em 0 0; font-size: 9pt; color: #666; display: flex; align-items: center; gap: 6px; }
.zt-login-trouble .zt-fa { color: var(--zeal-green); }
.zt-login-trouble a { color: var(--zeal-tiffany); font-weight: 600; text-decoration: none; }
.zt-login-trouble a:hover { text-decoration: underline; }
.zt-login-row { margin-bottom: 0.75em; }
.zt-login-row label { display: block; font-weight: 600; margin-bottom: 0.25em; color: #444; }
.zt-login-remember { display: flex; align-items: center; gap: 0.5em; margin: 0 0 1em; color: #444; }
.zt-login .ztbtn-lg { width: 100%; justify-content: center; }
.zt-login-hint { margin-top: 1.2em; font-size: 0.8rem; color: #999; }
.zt-login .validation-message { color: var(--zeal-red); font-size: 0.82rem; }

/* ---- RESPONSIVE -------------------------------------------------------- */
@media only screen and (max-width: 599px) {
	:root { --zeal-field-max: 100%; }
	.ztbtn { padding: 0.5rem 0.7rem; font-size: 0.85rem; }
	.zt-phone-country { max-width: 108px; font-size: 0.92rem; }
}

/* ---- ZT INPUT COLOR — FULL-WIDTH SWATCH + CENTERED VALUE READ-OUT (ported from Zeal.Razor.Ui) ---- */
/* THE SWATCH FILLS WITH THE PICKED COLOUR (inline background) AND CENTERS ITS "Rr,Gg,Bb (#HEX)" VALUE
   BOTH WAYS; THE TRANSPARENT NATIVE type=color SITS ON TOP AS THE CLICK LAYER (opens the OS picker). */
.zt-color-field {
	position: relative;
	width: 100%;
	height: 34px;
	display: flex;
	align-items: center;
	justify-content: center;
	border: 1px solid var(--zeal-light-grey);
	border-radius: var(--zeal-radius);
	overflow: hidden;
	cursor: pointer;
	box-sizing: border-box;
}

/* UNSET — A LIGHT CHECKER SAYS "NO COLOUR CHOSEN YET". */
.zt-color-field.zt-color-unset {
	background-color: #fafafa;
	background-image:
		linear-gradient(45deg, #e9e9e9 25%, transparent 25%, transparent 75%, #e9e9e9 75%),
		linear-gradient(45deg, #e9e9e9 25%, transparent 25%, transparent 75%, #e9e9e9 75%);
	background-size: 12px 12px;
	background-position: 0 0, 6px 6px;
}

/* THE CENTERED READ-OUT — INK COLOUR IS SET INLINE FOR CONTRAST OVER THE PICKED COLOUR. */
.zt-color-text {
	position: relative;
	z-index: 1;
	font-size: 0.85rem;
	font-weight: 700;
	letter-spacing: 0.02em;
	white-space: nowrap;
	pointer-events: none;      /* CLICKS FALL THROUGH TO THE NATIVE INPUT BELOW */
}
.zt-color-unset .zt-color-text { color: var(--zeal-medium-grey); font-weight: 600; }

/* THE NATIVE INPUT COVERS THE WHOLE SWATCH, INVISIBLE — IT ONLY CAPTURES THE CLICK + HOLDS THE VALUE.
   THE INTERACTIVE UPGRADE SWAPS IT FOR A <button>.zt-color-trigger (opens ZtColorPickerOverlay) — same skin. */
.zt-input-field-control input.zt-color-native,
.zt-input-field-control button.zt-color-trigger {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	margin: 0;
	padding: 0;
	border: none;
	background: transparent;
	opacity: 0;
	cursor: pointer;
}

/* ---- ZT COLOR PICKER OVERLAY — hex-first, explicit Apply/Clear/Cancel (rides the .zt-datepicker shell) ---- */
.zt-colorpicker-panel { width: min(420px, 94vw); }
.zt-colorpicker-title { font-size: 1.05rem; font-weight: 700; color: var(--zeal-dark-grey); }
.zt-colorpicker-hex { display: flex; align-items: center; gap: 0.6rem; margin: 0.35rem 0 0.75rem; }
.zt-colorpicker-hex-label { font-weight: 700; font-size: 0.9rem; color: var(--zeal-dark-grey); }
.zt-colorpicker-hex-input {
	flex: 1 1 auto; min-width: 0; height: 40px; box-sizing: border-box;
	border: 1px solid var(--zeal-light-grey); border-radius: var(--zeal-radius);
	padding: 0 0.65rem; font-size: 1.05rem; font-family: Consolas, 'Courier New', monospace;
	letter-spacing: 0.06em;
}
/* THE PENDING SWATCH — taller than the field swatch (it is the star here); the invisible OS input on top. */
.zt-colorpicker-swatch { height: 64px; }
.zt-colorpicker-swatch input.zt-color-native {
	position: absolute; inset: 0; width: 100%; height: 100%;
	margin: 0; padding: 0; border: none; background: transparent; opacity: 0; cursor: pointer;
}
.zt-colorpicker-hint { font-size: 9.5pt; color: #666666; line-height: 1.5; margin: 0.6rem 0 0.85rem; }
/* CANCEL LEFT, PRIMARY RIGHT — the spacer pushes Apply to the right edge. */
.zt-colorpicker-actions { display: flex; align-items: center; gap: 0.5rem; }
.zt-colorpicker-actions > :last-child { margin-left: auto; }

/* WAVE FINE PRINT — the smaller descriptive/warning copy inside a wave editor (the legacy
   ContentSectionFinePrint voice). Links inside it read as LINKS: blue + bold (Vince, 2026-08-12). */
.zt-wave-fineprint { font-size: 9.5pt; line-height: 1.6; color: #666666; margin: 0; }
.zt-wave-fineprint a { color: #007bff; font-weight: 700; text-decoration: none; }
.zt-wave-fineprint a:hover { text-decoration: underline; }

/* WAVE SUBHEAD — the small titled divider INSIDE a wave whose body carries several distinct blocks (the sales
   package editor: funnel, three tables, the link, the offer form, the example). A wave's own header already
   names the screen, so this is deliberately quieter than a section title: the same tiffany icon + grey text as
   a ZtPageSection heading, at a size that reads as "next part of this", not "new screen".
   ⚠️ align-self: stretch IS LOAD-BEARING — .zt-wave-body is a flex COLUMN, so a block-level child shrinks to
   its content width and centres itself instead of spanning (the same trap the duplicate-file list hit). */
.zt-wave-subhead {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	align-self: stretch;
	width: 100%;
	margin: 0.35rem 0 0;
	font-size: 1.02rem;
	font-weight: 700;
	color: #555555;
}

.zt-wave-subhead .zt-fa { color: var(--zeal-tiffany, #0abab5); font-size: 1.05rem; }

/* ---- SALES PACKAGE EDITOR ---- */
/* THE WHOLE EDITOR IS SECTION CARDS NOW (Vince, 2026-08-18: "let's do the same for members enrolled, steps
   reached, traffic sources, sign up link, and the offer"). The wave's blocks were a mix of flat grey subheads
   and one green section; every one of them is now the house shell, in the tones the offer page already uses —
   results GREEN (funnel, members, the link, the offer), diagnostics BLUE (steps reached, traffic sources).

   ⚠️ ZERO THE MARGIN, DON'T TOUCH THE GAP. .zt-section-card carries margin-bottom:0.75em for a normal page
   stack, but .zt-wave-body spaces its children with a flex GAP. Both apply, so every card would sit in a
   visibly bigger hole than the rest of the wave. Drop the margin and let one rhythm govern. */
.zt-wave-body .zt-section-card,
.zt-wave-look .zt-section-card { margin-bottom: 0; }

/* THE ANALYTICS STACK, WHEREVER IT LANDS. ZtSalesPackageAnalytics renders four section cards and is dropped
   BOTH into the offer page and into the wave — so it has to space its own children rather than rely on the
   surface underneath it (in the wave the rule above has just zeroed their margins, and a plain block wrapper
   would collapse all four together). Owning the gap here means it stacks identically in both homes. */
.zt-package-analytics { display: flex; flex-direction: column; gap: var(--zt-wave-gap, 0.85rem); }
.zt-package-analytics > .zt-section-card { margin-bottom: 0; }

/* A SHALLOWER SHADOW INSIDE A WAVE (Vince, 2026-08-18: "a little bit too much drop shadow"). The page shadow
   is right on a page — the card is floating over the layout background. Inside a wave it is floating over a
   white panel that is ITSELF already floating over the app, so the same shadow reads as twice the lift.
   ⚠️ SCOPED TO THE WAVE ON PURPOSE — .zt-action-card's shadow is the whole app's and is not this change.
   (.zt-wave-look rides along: its sections wear the identical clothes, so the identical lift.) */
.zt-wave-body .zt-section-card,
.zt-wave-look .zt-section-card { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); }

/* ⚠️⚠️ FIELDS INSIDE A SECTION CARD IN A WAVE NEED THE GAP RE-ESTABLISHED — THE SAME TRAP AS
   .zt-page-content-body ABOVE, AND IT BIT AGAIN (Vince, 2026-08-18: "all of the form fields in the second
   section are bunched together. I have no idea what happened there").

   HERE IS WHAT HAPPENS. `.zt-wave-body .zt-input-container { margin: 0 }` strips every control's own margin,
   because the wave body supplies the rhythm through its flex GAP. But a flex gap only reaches DIRECT children —
   and once the fields move inside a ZtActionCard they are grandchildren. They lose the margin AND never receive
   the gap, so they sit flush against one another.

   Why some sections looked fine: ZtMultiSelect and ZtRichTextEditor bring their own internal spacing, so Event
   Hosts, Required Documents and the Invitation were unaffected — which is exactly why only the plain-input
   sections looked broken.

   Re-declaring the SAME variable means a section's fields breathe exactly like top-level ones, from one source
   of truth. This is now the standard for every multi-section wave form, not a one-off for events. */
.zt-wave-body .zt-action-card-body,
.zt-wave-look .zt-action-card-body { display: flex; flex-direction: column; gap: var(--zt-wave-gap); }
.zt-wave-body .zt-action-card-body > *,
.zt-wave-look .zt-action-card-body > * { flex-shrink: 0; }

/* FINE PRINT THAT SITS INSIDE A CARD, not as a direct child of the flex body. .zt-wave-fineprint is margin:0
   because the body's gap normally does the spacing; there is no gap in here, so it pays its own way —
   -analytics-note follows the tiles it explains, -section-note precedes the control it introduces. */
.zt-package-analytics-note { margin-top: 0.7rem; }
.zt-package-section-note { margin-bottom: 0.7rem; }
/* THE SIGN-UP LINK ROW — the url in a monospaced plate with the Copy button beside it. It WRAPS rather than
   ellipsising on a narrow screen: a link you cannot read all of is a link you cannot type out by hand when the
   clipboard is unavailable, which is exactly the case the button already has to cope with. */
.zt-package-link {
	display: flex;
	align-items: center;
	flex-wrap: wrap;
	gap: 0.6rem;
	align-self: stretch;
	width: 100%;
}

.zt-package-link-url {
	flex: 1 1 12rem;
	/* min-width:0 SO A LONG SLUG CAN WRAP INSTEAD OF FORCING THE ROW WIDER THAN THE WAVE. */
	min-width: 0;
	overflow-wrap: anywhere;
	padding: 0.45rem 0.7rem;
	border: 1px solid #e0e0e0;
	border-radius: 5px;
	background: #f7f7f7;
	font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
	font-size: 0.9rem;
	color: #444444;
}

/* (THE EXAMPLE BLOCK'S STYLES ARE GONE — Vince dropped the section itself on 2026-08-18.) */

/* ---- ZT STAT TILE — one headline figure, in the house tone palette ---- */
/* THE GRID FILLS THE ROOM IT IS GIVEN. auto-fit + minmax is the responsive mechanism here rather than a stack of
   breakpoints: the tiles reflow at whatever width the container actually has, which is the only thing that works
   when the SAME grid appears full-page on a tenant site and inside a wave editor. 11rem is a real minimum (the
   longest label, "Monthly recurring", plus its caption), and at 360px it yields the two-across layout the
   production screen has — no invented width, and nothing to keep in step with the MD3 ladder. */
.zt-stat-grid {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
	gap: 0.75rem;
	align-self: stretch;
	width: 100%;
}

.zt-stat {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	gap: 0.15rem;
	/* min-width:0 SO A LONG CAPTION CAN WRAP instead of forcing the grid track wider (charter rule 7). */
	min-width: 0;
	padding: 0.9rem 0.75rem;
	border-radius: 5px;
	text-align: center;
}

/* THE FIGURE. clamp() rather than a media query — it is one value that should simply be big, and it has to look
   deliberate at every width from a folded phone to a desktop without three rules to keep in step. */
.zt-stat-value {
	font-size: clamp(1.6rem, 1.2rem + 1.4vw, 2.1rem);
	font-weight: 700;
	line-height: 1.1;
	color: #444444;
	overflow-wrap: anywhere;
}

.zt-stat-label {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 0.4rem;
	min-width: 0;
	font-size: 0.92rem;
	color: #555555;
}

.zt-stat-caption { font-size: 0.78rem; line-height: 1.3; color: #777777; }

/* ⚠️ THE TONE IS THE ROW-CARD TONE MINUS THE TAB. Fill + full-strength border + accent icon, exactly as
   .zt-flagged-* — but NO 4px left tab: that marks an OBJECT IN A LIST as the eye runs down a column of rows, and
   on a centred tile in a grid it reads as a rendering fault. Everything else matches, so a tile and a row card
   are recognisably the same family. */
.zt-stat-happy    { background-color: var(--zt-happy-bg);   border: 1px solid var(--zeal-green); }
.zt-stat-warn     { background-color: var(--zt-warn-bg);    border: 1px solid var(--zt-gold-accent); }
.zt-stat-error    { background-color: var(--zt-error-bg);   border: 1px solid var(--zeal-red); }
.zt-stat-neutral  { background-color: var(--zt-neutral-bg); border: 1px solid var(--zeal-blue); }
.zt-stat-grey     { background-color: var(--zt-plain-bg);   border: 1px solid var(--zeal-silver); }
.zt-stat-selected { background-color: var(--zt-neutral-bg); border: 1px solid var(--zeal-purple); }

.zt-stat-happy    .zt-stat-icon { color: var(--zeal-green); }
.zt-stat-warn     .zt-stat-icon { color: var(--zt-gold-accent); }
.zt-stat-error    .zt-stat-icon { color: var(--zeal-red); }
.zt-stat-neutral  .zt-stat-icon { color: var(--zeal-blue); }
.zt-stat-grey     .zt-stat-icon { color: var(--zeal-silver); }
.zt-stat-selected .zt-stat-icon { color: var(--zeal-purple); }

/* THE TWO PRICE LINES ON THE WIZARD'S FIRST STEP — "$215.00 today", "then $169.00 monthly starting…".
   ⚠️ THIS IS A TONE FIX, NOT DECORATION. A ZtContentItem inherits the body default (1rem, #333) while the
   paragraph above it is .zt-wave-intro (11.5pt, #555), so the same <b> rendered visibly heavier and darker
   down here than in the prose — Vince, 2026-08-16: "that bold text down there is, like, really bold… bolder
   than the text at the top and the instructions." Matching the prose's size and colour, and dropping the
   emphasis to 600, leaves the figures as the thing your eye lands on without them shouting. */
.zt-package-figures .zt-content-item { font-size: 11.5pt; color: #555555; }
.zt-package-figures b { font-weight: 600; }

/* THE OFFER SUMMARY ON THE PUBLIC WIZARD'S FIRST STEP — same shape, on the visitor's side. */
.zt-package-offer {
	align-self: stretch;
	width: 100%;
	line-height: 1.55;
	color: #444444;
}

.zt-package-offer p { margin: 0 0 0.6rem; }
.zt-package-offer p:last-child { margin-bottom: 0; }

/* CARD-ENTRY FINE PRINT — the "you'll be charged X today" line that sits between the card fields and the
   button that takes the money. Quiet, but not so quiet it reads as decoration. */
.zt-card-entry-fineprint {
	align-self: stretch;
	width: 100%;
	margin-top: 0.25rem;
	font-size: 9.5pt;
	line-height: 1.6;
	color: #666666;
}

/* ---- ZT ITEM CARD — THE one shared item/summary card (ported from Zeal.Razor.Ui) ---- */
/* ⚠️ ON THE SHARED ROW-CARD TONE NOW (Vince, 2026-08-16: "do a sweep of the system and update all of the rest of
   the controls for that … we need this to be consistent through the whole site").

   It used to carry #cfe3cf / #f8fcf8 — a PALE green border over a green-tinted white, both literals predating the
   token family. That is the same "light accent" the program-membership rows were pulled off in this pass, and it
   is why an item card and a detail card sitting in the same section read as two different kinds of object: one
   drew a whisper, the other drew its state. Item cards are green by construction (there is no Tone parameter), so
   the values here are exactly .ztdc-green's, tab included. */
.ztic {
	display: flex;
	align-items: flex-start;
	gap: 4px;                  /* WHISPER GAP: BIG ICON | 4px | CONTENT */
	border: 1px solid var(--zeal-green);
	border-left: 4px solid var(--zeal-green);
	background-color: var(--zt-happy-bg);
	--zt-row-glow: var(--zt-happy-glow);
	border-radius: 8px;
	padding: 12px 14px;
	margin-top: 0.5em;
	margin-bottom: 0.5em;
	max-width: 599px;
	transition: box-shadow 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
}

/* ⚠️ HIGHLIGHT IS AN INSET RING NOW, NOT A DARKER BORDER. Its whole job was to say "this one" by taking the full
   --zeal-green border while an ordinary card had the pale one — and now every card has the full border, so that
   difference no longer exists to spend. A ring inside the edge reads as emphasis at any border colour, and it is
   the same construction .zt-option-choice-selected already uses for "the one you picked". */
.ztic-highlight {
	box-shadow: 0 0 0 1px var(--zeal-green) inset;
}

.ztic-clickable { cursor: pointer; }
/* INERT — the "you cannot do this yet" state that came in when ZtManageCard was folded into this control.
   Muted rather than greyed out: the card is still saying something true, it just cannot be acted on. */
.ztic-static { cursor: default; opacity: 0.72; }

/* THE SHARED ROW LIFT, off the same token as ZtDetailCard and ZtFlaggedItem. The border and background no longer
   change on hover: they were the "pale → full green" transition that the base card has now taken permanently. */
@media (hover: hover) {
	.ztic-clickable:hover { box-shadow: 0 2px 8px var(--zt-row-glow); }
	.ztic-clickable.ztic-highlight:hover { box-shadow: 0 0 0 1px var(--zeal-green) inset, 0 2px 8px var(--zt-row-glow); }
}

/* SAME DISC AS .ztdc-icon — see the note there. One treatment for "the big icon on the left of a card",
   whichever card control drew it, so the two never drift apart. */
.ztic-icon {
	font-size: 1.35rem;
	line-height: 1;
	flex: 0 0 auto;
	align-self: center;        /* CENTER THE BIG ICON VERTICALLY AGAINST THE CARD CONTENT */
	color: var(--zeal-green);
	width: 2em;
	height: 2em;
	border-radius: 50%;
	background: var(--zt-icon-disc);
	display: inline-flex;
	align-items: center;
	justify-content: center;
}

.ztic-body {
	flex: 1 1 auto;
	min-width: 0;
}

/* HEADER — TITLE (LEFT) + BADGE PILLS (RIGHT) ON ONE LINE. */
.ztic-header {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 8px;
	flex-wrap: wrap;
}

.ztic-title {
	font-weight: 700;
	font-size: 0.98rem;
	line-height: 1.2;
	word-break: break-word;
	color: var(--zeal-dark-grey);   /* EXPLICIT — HOST PAGES MAY DEFAULT TEXT TO WHITE (e.g. dark backdrops) */
}

.ztic-badges {
	display: flex;
	gap: 6px;
	flex-wrap: wrap;
}

.ztic-badge {
	display: inline-flex;
	align-items: center;
	gap: 4px;
	font-size: 0.68rem;
	font-weight: 700;
	letter-spacing: 0.03em;
	padding: 2px 8px;
	border-radius: 999px;
	white-space: nowrap;
}

/* GREEN IS ALWAYS HAPPY. */
.ztic-badge-green { background-color: var(--zeal-green); color: var(--zeal-white); }
.ztic-badge-amber { background-color: var(--zeal-orange); color: var(--zeal-white); }
.ztic-badge-red { background-color: var(--zeal-red); color: var(--zeal-white); }
.ztic-badge-grey { background-color: #e6e6e6; color: var(--zeal-medium-grey); }

/* ACTION PILL — CLICKABLE (e.g. REFRESH). */
.ztic-badge-action {
	background-color: var(--zeal-white);
	color: #555555;
	border: 1px solid var(--zeal-light-grey);
	cursor: pointer;
	transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.ztic-badge-action:hover {
	background-color: var(--zeal-green);
	border-color: var(--zeal-green);
	color: var(--zeal-white);
}

.ztic-desc {
	font-size: 0.82rem;
	line-height: 1.3;
	color: #5a6b5a;
	margin-top: 2px;
}

.ztic-actions {
	display: flex;
	gap: 6px;
	flex-wrap: wrap;
	align-items: center;
	margin-top: 8px;
}

/* ⚠️ align-self CENTRES THE MANAGE CHIP AGAINST THE WHOLE CARD, and it has to be align-SELF rather than
   the parent's align-items: .ztic is align-items:flex-start ON PURPOSE — the big leading icon must sit
   level with the FIRST line of the title, not float in the middle of a four-line body. So the chip opts
   itself out of that rule instead of the card changing for everyone.
   Vince, 2026-08-23: "if we have to do this globally, that would be great… all of them are centered
   vertically." Global is right — a chip that hangs high is wrong on every card, not just this one. */
.ztic-action { flex: 0 0 auto; align-self: center; }

/* MANAGE CHIP — SHOWN WHEN THE WHOLE CARD IS CLICKABLE. */
.ztic-manage {
	display: inline-flex;
	align-items: center;
	gap: 5px;
	font-size: 0.8rem;
	font-weight: 700;
	color: var(--zeal-green);
	border: 1px solid var(--zeal-green);
	border-radius: 999px;
	padding: 3px 10px;
	background-color: var(--zeal-white);
	white-space: nowrap;
	transition: background-color 0.12s ease, color 0.12s ease;
}
.ztic-clickable:hover .ztic-manage {
	background-color: var(--zeal-green);
	color: var(--zeal-white);
}

/* ---- LOGIN BUSY OVERLAY — the LavaLoader (ZtBusy) shown while the sign-in POST is in flight ---- */
/* Hidden until the login form's onsubmit adds .zt-show. Fixed + full-screen so the inner .zt-busy-overlay
   (position:absolute; inset:0) covers the whole viewport, like the legacy "get busy" look. */
.zt-login-busy { display: none; position: fixed; inset: 0; z-index: 9999; }
.zt-login-busy.zt-show { display: block; }
/* ============================================================================
   THE GLOBAL PAGE LOADER — ONE OVERLAY, ONE RULE, FOR THE WHOLE APP.
   ============================================================================
   THE RULE:  the full-page LavaLoader is visible IFF SOMETHING HAS CLAIMED IT.

   A page NEVER renders its own page-level loader. It claims this one. That is the
   whole system, and it exists because the alternative was measured and is broken:
   a <ZtBusy> dropped at the top of a page island is `position:absolute; inset:0`
   against @Body, which has ZERO HEIGHT while the page is loading — and for the
   first ~460ms the page-transition wrapper's transform is its containing block.
   Measured: 990x0 during the transition, 1280x720 after it clears. So an in-page
   loader is invisible exactly when it is needed and then snaps to full screen.
   THIS overlay is a FIXED child of the shell root with no transformed ancestor,
   so it is always exactly the viewport.

   VISIBILITY RIDES html.zt-busy-shown, set by ztBusy and by nothing else. It is
   on <html> because Blazor's enhanced navigation PATCHES THE BODY: a marker
   parked in <body> gets diffed away mid-navigation, which is precisely the
   moment the claim has to survive. <html> is never replaced.

   Navigation, page loads and blocking actions all resolve to this ONE rule and
   this ONE overlay, so the user only ever sees a single loader.
   ---------------------------------------------------------------------------- */

.zt-page-busy { display: none; position: fixed; inset: 0; z-index: 9999; }

/* NO FADE-IN, DELIBERATELY. ⚠️ DO NOT ADD ONE.
   Two attempts were measured and BOTH fail closed — the loader ends up
   display:block at opacity 0, i.e. a page that looks hung:
     `140ms ease 180ms both`  → fill-mode `both` pins the `from` state (opacity 0)
                                for the whole delay, and forever if the clock stalls.
     `140ms ease` (no fill)   → a RUNNING-but-frozen animation still holds `from`.
   The CSS animation clock stops in a backgrounded or throttled tab (measured:
   getAnimations()[0].currentTime stuck at 0 after 400ms) while setTimeout keeps
   running — so anything that gates VISIBILITY on an animation can leave the user
   staring at nothing. The 180ms grace in ztBusy already does the real job here
   (fast pages never reveal it at all); once revealed, it must simply BE there. */
html.zt-busy-shown .zt-page-busy { display: block; }

/* BACK-COMPAT: .is-on was the old manual ztPageBusy toggle. ztPageBusy now routes
   through ztBusy claims; this stays so any straggler markup still works. */
.zt-page-busy.is-on { display: block; }

/* ============================================================================
   PAGE TRANSITION — a built-in entrance animation replayed on EVERY navigation.
   ONE place (the shell wraps @Body in .zt-page-transition); never per-page. The
   ztPageTransition JS re-triggers .zt-pt-play on enhancedload + pageshow. The
   effect is swappable by the wrapper's variant class (default = rise+fade). GPU-
   only (opacity/transform) so it stays smooth on mobile. Reduced-motion opts out.
   ============================================================================ */
.zt-page-transition { transform-origin: 50% 0; }
.zt-page-transition.zt-pt-play { animation: zt-page-in 460ms cubic-bezier(.22, .61, .36, 1) both; }
@keyframes zt-page-in {
	from { opacity: 0; transform: translateY(16px) scale(.99); }
	to   { opacity: 1; transform: none; }
}
/* VARIANTS — set one on the wrapper instead of the default to change the feel (one edit, whole app). */
.zt-page-transition.zt-pt-slide.zt-pt-play { animation-name: zt-page-slide; }
@keyframes zt-page-slide {
	from { opacity: 0; transform: translateX(28px); }
	to   { opacity: 1; transform: none; }
}
.zt-page-transition.zt-pt-zoom.zt-pt-play { animation-name: zt-page-zoom; }
@keyframes zt-page-zoom {
	from { opacity: 0; transform: scale(.955); }
	to   { opacity: 1; transform: none; }
}
.zt-page-transition.zt-pt-flip { perspective: 1400px; }
.zt-page-transition.zt-pt-flip.zt-pt-play { animation-name: zt-page-flip; transform-origin: 50% 40%; }
@keyframes zt-page-flip {
	from { opacity: 0; transform: rotateX(7deg) translateY(18px); }
	to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
	.zt-page-transition.zt-pt-play { animation: none !important; }
}

/* ── OPTION PICKER (ZtOptionPicker) ── the "pick one to continue" cards. Mobile-first: one column by default,
   two side-by-side only when THE PICKER ITSELF has room.
   ⚠️ THIS IS A CONTAINER QUERY, NOT A VIEWPORT ONE, AND THAT MATTERS. The old @media (min-width:560px) asked how
   wide the WINDOW was — fine inside the 760px wave panel, wrong the moment the same picker was dropped into the
   420px login card, where a desktop browser happily split it into two 200px columns. The picker now measures its
   own box, so it is right in the payment wizard, in the login card, and on a phone, with no per-use override.
   auto-fit + minmax does it INTRINSICALLY (no query to keep in step with any panel's width): a track never goes
   below 15rem, and min(100%,…) is what lets it fall to a single full-width column instead of overflowing. */
.zt-option-picker {
	display: grid;
	grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
	gap: 0.75rem;
}

/* ══ 🎨 THE OPTION CARD WEARS THE ROW-CARD TONE — SHIPPED SYSTEM-WIDE 2026-08-16 ══════════════════════════
   Trialled on the Create Connected Site screen behind an opt-in class, then promoted on sight. Vince: "man, this
   looks really slick. I love the way this looks. Let's have this be a system wide feature … green for happy,
   yellow for warning, red for error, then blue and gray for neutral. If we could build this into the control
   itself because, right, this is a reusable control. We're not inventing this every time."

   ⚠️ THE TONE IS A PARAMETER ON THE CONTROL, NOT A CLASS A CALL SITE REMEMBERS. `ZtOptionPicker.Tone` (and
   `ZtPickerOption.Tone` for a single odd-one-out) emits the class below; the trial's `zt-option-accent` opt-in is
   GONE. Green is the default, which is what every picker already looked like, so the twelve existing call sites
   needed no edit to pick this up. THAT is what "built into the control" has to mean — a look nobody can forget to
   ask for.

   THE FIVE PARTS, exactly as .ztdc-* and .zt-flagged-* wear them: pale token fill · full-strength accent border ·
   4px accent tab · accent icon · hover glow. Nothing here names a colour the tone table does not already own. */
.zt-option-choice {
	display: flex;
	flex-direction: column;
	gap: 0.4rem;
	min-width: 0;                       /* SO LONG WORDS WRAP INSTEAD OF WIDENING THE GRID TRACK */
	height: 100%;                       /* FILL THE EQUAL-HEIGHT GRID ROW so the button can sit on the floor */
	padding: 0.85rem 0.9rem;
	border: 1px solid var(--zeal-silver);
	border-left: 4px solid var(--zeal-silver);
	border-radius: var(--zt-radius, 0.5rem);
	background: var(--zt-plain-bg);
	--zt-row-glow: var(--zt-plain-glow);
	transition: box-shadow 0.12s ease;
}

/* THE TONES. Grey is the base above, so a card whose tone did not resolve still looks finished rather than bare. */
.zt-option-green { border-color: var(--zeal-green);     background: var(--zt-happy-bg);   --zt-row-glow: var(--zt-happy-glow); }
.zt-option-gold  { border-color: var(--zt-gold-accent); background: var(--zt-warn-bg);    --zt-row-glow: var(--zt-warn-glow); }
.zt-option-blue  { border-color: var(--zeal-blue);      background: var(--zt-neutral-bg); --zt-row-glow: var(--zt-neutral-glow); }
.zt-option-red   { border-color: var(--zeal-red);       background: var(--zt-error-bg);   --zt-row-glow: var(--zt-error-glow); }
.zt-option-grey  { border-color: var(--zeal-silver);    background: var(--zt-plain-bg);   --zt-row-glow: var(--zt-plain-glow); }

/* ④ THE HEAD ICON TAKES THE ACCENT, like the leading glyph on a detail card. */
.zt-option-green .zt-option-head .zt-fa { color: var(--zeal-green); }
.zt-option-gold  .zt-option-head .zt-fa { color: var(--zt-gold-accent); }
.zt-option-blue  .zt-option-head .zt-fa { color: var(--zeal-blue); }
.zt-option-red   .zt-option-head .zt-fa { color: var(--zeal-red); }
.zt-option-grey  .zt-option-head .zt-fa { color: var(--zeal-silver); }

/* ⚠️ SELECTED IS AN INSET RING, NOT A DARKER BORDER — the base card has taken the full accent permanently, so the
   border can no longer be the difference. Same trade `.ztic-highlight` made in the row-card sweep. The ring is
   `currentcolor`-free on purpose: it reads off the SAME tone token the border does, so a red option's "chosen"
   ring is red rather than a stray green. */
.zt-option-choice-selected { box-shadow: 0 0 0 1px var(--zeal-silver) inset; }
.zt-option-green.zt-option-choice-selected { box-shadow: 0 0 0 1px var(--zeal-green) inset; }
.zt-option-gold.zt-option-choice-selected  { box-shadow: 0 0 0 1px var(--zt-gold-accent) inset; }
.zt-option-blue.zt-option-choice-selected  { box-shadow: 0 0 0 1px var(--zeal-blue) inset; }
.zt-option-red.zt-option-choice-selected   { box-shadow: 0 0 0 1px var(--zeal-red) inset; }

.zt-option-head {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	min-width: 0;
}

.zt-option-name { min-width: 0; overflow-wrap: anywhere; }

.zt-option-desc {
	font-size: 0.92rem;
	line-height: 1.25;
	overflow-wrap: anywhere;
}

/* SELECT SITS ON THE BOTTOM-LEFT OF THE CARD, ALWAYS — margin-top:auto pushes it to the floor so the button
   lands in the same place whether the description is one line or four. Without this it just follows the text. */
.zt-option-select {
	margin-top: auto;
	padding-top: 0.5rem;
	align-self: flex-start;
}

/* ⑤ THE HOVER LIFT — the tone supplies --zt-row-glow above, so this never names a colour.
   ⚠️ (hover: hover) — charter rule 7a; a tapped card must not keep the glow.
   A SELECTED card keeps its inset ring UNDER the glow, so "chosen" survives the hover.
   ⚠️ `box-shadow` does not cascade-merge: naming the glow alone on a selected card would DROP its ring, which is
   why the selected variants restate both layers instead of relying on the rule above them. */
@media (hover: hover) {
	.zt-option-choice:hover { box-shadow: 0 2px 8px var(--zt-row-glow); }
	.zt-option-choice-selected:hover { box-shadow: 0 0 0 1px var(--zeal-silver) inset, 0 2px 8px var(--zt-row-glow); }
	.zt-option-green.zt-option-choice-selected:hover { box-shadow: 0 0 0 1px var(--zeal-green) inset, 0 2px 8px var(--zt-row-glow); }
	.zt-option-gold.zt-option-choice-selected:hover  { box-shadow: 0 0 0 1px var(--zt-gold-accent) inset, 0 2px 8px var(--zt-row-glow); }
	.zt-option-blue.zt-option-choice-selected:hover  { box-shadow: 0 0 0 1px var(--zeal-blue) inset, 0 2px 8px var(--zt-row-glow); }
	.zt-option-red.zt-option-choice-selected:hover   { box-shadow: 0 0 0 1px var(--zeal-red) inset, 0 2px 8px var(--zt-row-glow); }
}

/* ── STRIPE ELEMENTS MOUNT (ZtInputStripe) ── Stripe drops an iframe in here, so the box has to look like our
   other inputs while owning none of the content. Height matches a normal input so the form rhythm holds. */
.zt-stripe-input > div {
	min-height: 2.6rem;
	padding: 0.72rem 0.6rem;
}

/* ── DELETE-CARD CONFIRM ── the real card visual, centred and capped so it reads as a preview rather than an
   editable item. Shrinks with the panel on small screens. */
.zt-delete-card-preview {
	display: flex;
	justify-content: flex-start;   /* LEFT-ALIGNED with the title/body text, not floating centre */
	max-width: 22rem;
}

.zt-delete-card-preview > * { width: 100%; }

/* ── ALERT ICON ── larger and vertically centred against the whole message, not pinned to the first line.
   A multi-line alert previously left the little triangle floating up by the first word. */
.zt-alert {
	align-items: center;
}

.zt-alert > .zt-icon,
.zt-alert .zt-alert-icon {
	align-self: center;
	font-size: 1.6rem;
	line-height: 1;
	flex: 0 0 auto;
}

/* ── BANK ACCOUNT VISUAL (ZtBankAccount) ── the sibling of the credit-card visual. Inherits .zt-cc for the
   silhouette; only the brand row and the warning tag differ. Same responsive behaviour as .zt-cc (it stacks
   inside .zt-cc-list at the existing 480px breakpoint), so nothing new to tune for small screens. */
.zt-bank .zt-bank-mark {
	display: flex;
	align-items: center;
	gap: 0.45rem;
	min-width: 0;                       /* SO A LONG BANK NAME WRAPS INSTEAD OF PUSHING THE ACTIONS OFF */
	font-weight: 700;
	letter-spacing: 0.01em;
	overflow-wrap: anywhere;
}

.zt-bank .zt-bank-mark .zt-icon { font-size: 1.15rem; }

/* Unverified / errored — the account is on file but can't be billed yet, so say so rather than let it pass
   for ready. */
.zt-cc-tag-warn {
	background: var(--zt-warn-bg, #fefdf8);
	color: #7a5b00;
	border: 1px solid var(--zt-warn-bd, #f6f0db);
}

/* ── BANK ACCOUNT VISUAL (ZtBankAccount) ── it reuses .zt-cc wholesale, so only the two differences need rules:
   the bank mark that replaces the brand logo plate, and the unverified-status tag. */
/* UNVERIFIED / ERRORED — on file but not billable yet. Sits alongside the funding/default tags.

   IT IS A PILL, EXACTLY LIKE ITS SIBLINGS. This rule used to set colour ONLY, so the tag inherited the CARD's
   font size with no padding and no radius: "VERIFICATION FAILED" rendered as big bare text in a hairline box
   that wrapped onto its own line and pushed the tag row off-centre. Everything below except the colours is the
   same treatment as .zt-cc-funding / .zt-cc-default, so the three read as one set.

   AMBER, NOT the near-white --zt-warn-bg: that token is a full-panel wash meant to sit behind body text, and at
   pill size on a tinted card it disappears. A status worth interrupting for has to be legible at a glance. */
.zt-cc-warn {
	font-size: 0.58rem;
	font-weight: 800;
	letter-spacing: 0.08em;
	padding: 0.14rem 0.5rem;
	border-radius: 999px;
	background: #ffcb04;
	color: #4a3600;
	border: 1px solid #e0b200;
	/* NEVER let a long status ("VERIFICATION FAILED") break across two lines inside the pill. */
	white-space: nowrap;
}






/* ── CHARGE CARD (ZtChargeCard) ── ONE Stripe charge, shaped like the legacy ViewStripeChargeActionCard:
   a SOLID coloured header band (colour = the charge's status), the summary always visible under it, and the
   detail sections in the collapsible body.

   THE CARD CARRIES NO HORIZONTAL PADDING — the CONTENT is padded instead. Earlier attempts kept the card's
   1.3em padding and used negative margins to push the bands out to the edge, which cannot work: the collapse
   wrapper (.zt-collapse-inner) sets overflow:hidden for the slide animation, so anything reaching past its box
   is CLIPPED at exactly the padding line. Removing the padding makes the bands full-bleed by construction. */
.zt-charge-card {
	margin-bottom: 1rem;
	overflow: hidden;                    /* clip the bands to the card's 6px radius, all four corners */
	/* THE 6px TOP ACCENT IS BACK (it used to be zeroed, because the saturated band WAS the status). The band is
	   now a near-white tint, so on a long stack of charges the one DISPUTED card has to be findable by colour
	   from across the page — the accent is what does that, and it is the same accent every other accented
	   .zt-action-card in the app already carries. No new colour: .zt-action-card-{red,green,blue,gold} sets it. */
	padding: 0 0 1.25em;                 /* NO side padding — see the note above */
	/* THE BREATHING ROOM ABOVE A CARD WE SCROLL TO. ztCharges.focus() uses block:'start', which would otherwise
	   jam the card's top edge flush against the top of the view. Expressed as scroll-margin rather than as maths
	   in JS so the BROWSER applies it — against whichever ancestor actually scrolls, on any screen size. Tunable
	   here in one place; override the variable per-breakpoint if a layout ever grows a sticky top bar. */
	scroll-margin-top: var(--zt-charge-scroll-offset, 14px);
}

/* HEADER BAND — full width, and it supplies its own inset.

   THE BAND IS A TINT, NOT A SLAB. It used to be a saturated block (bottle green / #9b1c1c / solid blue) with
   white text — the legacy's look, but nobody else's here. EVERY other header band in the app is the pale
   --zt-*-bg wash with the matching dark tone on the text and icon: the flush section cards (.zt-happy /
   .zt-error / .zt-neutral), the wave headers, the analytics sections. The charge cards were the one screen
   still speaking the old dialect, so they now use the same four tones as everything else. */
.zt-charge-card .zt-action-card-title {
	display: flex;
	align-items: center;
	gap: 0.55rem;
	margin: 0 0 0.75em;
	padding: 0.7em 1.3em;
	font-size: 1rem;
	background: var(--zt-plain-bg);
	border-bottom: 1px solid #e4e4e4;
	color: var(--zeal-dark-grey);
}

/* THE WHOLE BAND TAKES THE TONE COLOUR — title text, icon and toggle switch alike. The tints are near-white by
   design, so the TEXT is what carries the status; leaving the icon on its own CardStyle hue would put a vivid
   --zeal-red next to a dark-red title on a #fdf8f8 wash, i.e. two different reds in one band. */
.zt-charge-card .zt-action-card-title * { color: inherit; }

/* THE TOGGLE — a switch, like the legacy. */
.zt-charge-card .zt-collapse-toggle { background: none; border: none; padding: 0; cursor: pointer; }
.zt-charge-card .zt-toggle-switch { font-size: 1.35rem; line-height: 1; opacity: 0.95; }

/* STATUS COLOURS — the legacy's BuildCardOptions mapping, decided server-side, in the house tones:
   green = happy · red = error · blue = neutral · gold = warn. */
.zt-charge-card.zt-action-card-blue  .zt-action-card-title { background: var(--zt-neutral-bg); border-bottom-color: var(--zt-neutral-bd); color: var(--zeal-dark-blue); }
.zt-charge-card.zt-action-card-red   .zt-action-card-title { background: var(--zt-error-bg);   border-bottom-color: var(--zt-error-bd);   color: var(--zeal-dark-red); }
.zt-charge-card.zt-action-card-green .zt-action-card-title { background: var(--zt-happy-bg);   border-bottom-color: var(--zt-happy-bd);   color: var(--zeal-dark-green); }
.zt-charge-card.zt-action-card-gold  .zt-action-card-title { background: var(--zt-warn-bg);    border-bottom-color: var(--zt-warn-bd);    color: var(--zeal-dark-gold); }

/* CONTENT gets the inset the card no longer has — everything EXCEPT the section bands. */
.zt-charge-card .zt-action-card-notes,
.zt-charge-card .zt-action-card-body > .zt-content-items { padding: 0 1.3em; }

.zt-charge-card .zt-action-card-body { padding: 0; }

/* SECTION BANDS — edge to edge because nothing above them is padded.
   NO top/bottom BORDERS: a 1px line running the full width terminates hard against the card's clipped, rounded
   edge and reads as the band "breaking up" at each end. The background change alone separates the sections.

   ⚠️ ONE DEFINITION, TWO NAMES. .zt-card-section-title is the GENERIC control (ZtCardSection) and
   .zt-charge-section-title is the charge card's original private class, kept because that card's markup is
   untouched. They are listed together so a band can never drift between the two screens. */
.zt-charge-section-title {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	margin: 0.9em 0 0.5em;
	padding: 0.5em 1.3em;
	background: var(--zeal-lightest-grey, #f1f2f4);
	font-size: 0.86rem;
	font-weight: 700;
	color: var(--zeal-dark-grey);
}

.zt-charge-section-title.zt-charge-danger { background: var(--zt-error-bg); color: var(--zeal-dark-red); }
.zt-charge-section-title.zt-charge-danger .zt-icon { color: #9b1c1c; }

/* THE CHARGES SECTION — the SAME shape as the connected-account (programs) section: a flush ZtActionCard whose
   green "happy" header band carries the identity (avatar + name + "Charges") on the left and Filters on the
   right, with the charge cards stacked in the body. */
/* ⚠️ SECTIONS NEVER SET THEIR OWN WIDTH — THE SHELL COLUMN IS THE ONE WIDTH AUTHORITY. .zt-shell-content
   (ZtShellLayout.razor.css) sizes and centres every authenticated page; a section fills whatever it is given,
   so every page is exactly as wide and exactly as centred as the user home. The old per-screen caps
   (max-width: 820px on .zt-charges-section and .zt-analytics-section — with no margin auto, so those pages
   ALSO hugged the left of the column) are what made Charges / Payouts / Recharge / Insights / Revenue /
   Site Analytics render as a narrower, off-centre page (Vince, 2026-08-20: "we can't go page by page and
   hard-code this"). If a future section genuinely wants a narrower measure, that is a shell/system question —
   never a one-off max-width on a section class. */
/* ── ANALYTICS SECTIONS ─────────────────────────────────────────────────────────────────────────────────
   Each analytics block is its OWN top-level card rather than a card nested in one big account card. Nesting
   meant the page's wave background never showed between them, and every block sat under the same redundant
   account header. Standalone cards let the wave through and let each block name itself.
   The light-green title band is the same --zt-happy tint the flush headers use; ZtActionCardChart has no
   FilledHeaderFooter to inherit it from, so it is stated here for every section on the page. */
/* THE TITLE BAND IS FULL-BLEED — it spans the whole card, edge to edge, like the flush header on the account
   cards. That only works if the CARD carries no side padding: a padded card insets the band and leaves white
   gutters either side of it. So the padding moves OFF the card and ON to the content, exactly the arrangement
   .zt-charge-card uses, and overflow:hidden clips the band to the card's rounded corners. */
.zt-analytics-section {
	margin-bottom: 1rem;
	overflow: hidden;
	border-top-width: 0;                 /* the band replaces the top-border accent */
	padding: 0 0 1.25em;                 /* NO side padding — see the note above */
}

/* ⚠️ DIRECT CHILD (>), NEVER A DESCENDANT. A section card can CONTAIN other action cards — the Disputed Charges
   section hosts a full ZtChargeList, and every charge in it is an action card of its own. A descendant selector
   reached into those nested cards and gave each one the section's title band AND a second 1.3em inset on top of
   the padding it already had, so the very same ZtChargeList rendered visibly differently here than on the user
   charges page. The component was shared all along; only this selector's reach was wrong. */
.zt-analytics-section > .zt-action-card-title {
	display: flex;
	align-items: center;
	gap: 0.55rem;
	margin: 0 0 0.9em;
	padding: 0.75em 1.3em;
	background: var(--zt-happy-bg);
	border-bottom: 1px solid var(--zt-happy-bd);
}

/* CONTENT GETS THE INSET THE CARD NO LONGER HAS — again, this card's own body only. */
.zt-analytics-section > .zt-action-card-body,
.zt-analytics-section > .zt-action-card-notes { padding: 0 1.3em; }

/* ── FLAGGED ITEM (ZtFlaggedItem) ───────────────────────────────────────────────────────────────────────
   A COMPACT TINTED ROW WITH AN ACCENT TAB. The shape is ported from the legacy
   MissingPaymentMethodItem.razor.scss — 8px padding, 5px radius, 4px between rows, and a 4px tab down the left
   edge, which is what makes a row scannable in a list. The COLOURS are the house tone variables rather than the
   legacy's literals; see the tone block below for why. */
.zt-flagged {
	display: block;
	margin-bottom: 4px;
	/* ⚠️ ASYMMETRIC ON PURPOSE (Vince, 2026-08-22: "the padding on the right between the delete button and
	   the edge of the control, there is not enough"). The LEFT already reads as 12px — 8px of padding plus
	   the 4px accent tab — so a uniform 8px leaves the right visibly tighter than the left. 12px on the
	   right makes the two sides match what the eye actually measures. */
	padding: 8px 12px 8px 8px;
	border-radius: 5px;
	color: inherit;
	text-decoration: none;
	/* NOT FULL WIDTH. Stretched edge to edge across a wide card the row becomes mostly empty space with its
	   content floating at the left, and the eye travels past nothing to reach the next row. The cap is a custom
	   property so a row that carries ACTIONS can widen itself without a second rule; max-width, not width, so a
	   phone still gets the full column. */
	max-width: var(--zt-flagged-max, 30rem);
}

/* THE LIST WRAPPER — REQUIRED INSIDE A WAVE EDITOR. .zt-wave-body is a flex column with a 0.85rem gap between
   its DIRECT children, so a bare list of rows got that gap ON TOP of each row's own 4px margin and the list
   sprawled. Wrapping them makes the whole list ONE flex child: the wave gap applies once, around the list, and
   the rows keep their own tight 4px rhythm inside it. */
.zt-flagged-list { display: block; }

/* CLICKABLE ROWS ONLY. .zt-flagged renders as a <div> when it has no destination, and a row that cannot be
   clicked must not claim it can — so the pointer and the hover lift hang off the element, not the class.
   A row whose destination is a POST (an "enter" endpoint — no URL to link to) renders as a <button>, so it
   gets the same treatment plus the reset that stops a button looking like a button. */
a.zt-flagged,
button.zt-flagged { cursor: pointer; }
/* THE SHARED ROW LIFT — the same glow, from the same per-tone token, that a ZtDetailCard and a ZtItemCard use, so
   the three row-cards respond to the mouse identically. The old `filter: brightness(0.985)` was a 1.5% darkening
   that was invisible on the pale tints these rows are made of. Guarded by (hover: hover) — charter rule 7a. */
.zt-flagged { transition: box-shadow 0.12s ease; }
@media (hover: hover) {
	a.zt-flagged:hover,
	button.zt-flagged:hover { box-shadow: 0 2px 8px var(--zt-row-glow, rgba(0, 0, 0, 0.10)); }
}

/* BUTTON RESET — width, alignment and typography come from the row, not from the user-agent's button styling.
   width:100% with the shared max-width above keeps it exactly the size the <a> and <div> variants are. */
button.zt-flagged {
	width: 100%;
	text-align: left;
	font: inherit;
	appearance: none;
}

/* THE ROW IS DENSE — the media object inside it is a name, not a profile header, so its default vertical
   rhythm is trimmed and the title carries the legacy's bold. */
.zt-flagged .ztmo { gap: 10px; }
.zt-flagged .ztmo-title { font-weight: 700; font-size: 0.95rem; }

/* THE FOUR TONES, ALL ON THE SHARED TONE VARIABLES so a flagged row matches the SECTION HEADER above it exactly
   — a row tinted a different yellow from its own title band reads as two unrelated things stacked together.

   ⚠️ DELIBERATE DEVIATION FROM THE LEGACY, at Vince's call: the legacy row was #ffffeb with a #f0c2c2 border and
   a RED tab. Red said "error" on a list that is a to-do, not a failure — so the tab and border are GOLD and the
   panel is the house warn tint. The shape, density and 4px tab width are still the legacy's.

   ⚠️ ALL FOUR NOW CARRY THE FULL ACCENT ON ALL FOUR SIDES (Vince, 2026-08-16: "we want the same for the yellow
   warning, the blue neutral, gray neutral, and red error — that same styling, but with obviously the appropriate
   colors for each"). Happy moved on its own last week and the note below records why the other three were left
   behind; that carve-out is now CLOSED. Leaving it open is what it looks like when a rule is applied to the row
   somebody was looking at instead of to the control — a green trial row and a gold missing-waiver row sitting in
   the same list, one with a real border and one with a hairline. */
.zt-flagged-warn    { background-color: var(--zt-warn-bg); border: 1px solid var(--zt-gold-accent); border-left: 4px solid var(--zt-gold-accent); --zt-row-glow: var(--zt-warn-glow); }
.zt-flagged-error   { background-color: var(--zt-error-bg); border: 1px solid var(--zeal-red); border-left: 4px solid var(--zeal-red); --zt-row-glow: var(--zt-error-glow); }
.zt-flagged-neutral { background-color: var(--zt-neutral-bg); border: 1px solid var(--zeal-blue); border-left: 4px solid var(--zeal-blue); --zt-row-glow: var(--zt-neutral-glow); }
/* GREY — the fifth tone, for a row that is simply an entry rather than a state. It had no class at all before, so
   a toneless flagged row fell through to no border whatsoever. */
.zt-flagged-grey    { background-color: var(--zt-plain-bg); border: 1px solid var(--zeal-silver); border-left: 4px solid var(--zeal-silver); --zt-row-glow: var(--zt-plain-glow); }
/* ⚠️ HAPPY NOW MATCHES A HAPPY DETAIL CARD (.ztdc-green) EXACTLY — same fill, same border — because the trial
   and prospect rows sit on the SAME PAGE as the Default-template row and read as two different kinds of thing
   (Vince, 2026-08-15: "the background and border of those is different than the zeal waves… if we could make the
   background and border the same as the zeal waves for the prospects and the trial class members").

   It used to carry the legacy's own values (#f8fcf7 panel, #cfe9d4 border), which predated the token family —
   the panel was a touch greener than --zt-happy-bg and the border a pale tint rather than the real accent, so a
   flagged row never quite matched the detail card beside it. THE 4px TAB SURVIVES, and it is what Vince asked to
   keep; against 1px sides a 4px left edge still reads as a tab even though it is now the same colour.

   (THE OLD "ONLY HAPPY MOVED" NOTE LIVED HERE. It said warn / error / neutral kept the pale-tint border on
   purpose, because the gold missing-waiver row was a treatment Vince had just signed off on. He closed that
   carve-out himself a day later — see the tone rules above — so all five now share one construction.) */
.zt-flagged-happy   { background-color: var(--zt-happy-bg); border: 1px solid var(--zeal-green); border-left: 4px solid var(--zeal-green); --zt-row-glow: var(--zt-happy-glow); }

/* NO ACCENT TAB — a plain list entry rather than a flagged one (the legacy's upcoming-charge row). The left
   border drops back to the hairline the other three sides use, so the row keeps its shape without claiming a
   signal it does not carry. */
/* ⚠️ "FLAT" MEANS NO TAB, NOT A DIFFERENT COLOUR. The left edge drops to the SAME hairline the other three sides
   use — so each of these has to track whatever its tone's border now is, or a flat row gets three accent sides and
   one pale one. All five moved together when the borders did; happy had already been fixed for this exact reason
   and is the pattern the other four now follow. */
.zt-flagged-flat { border-left-width: 1px; }
.zt-flagged-flat.zt-flagged-warn    { border-left-color: var(--zt-gold-accent); }
.zt-flagged-flat.zt-flagged-error   { border-left-color: var(--zeal-red); }
.zt-flagged-flat.zt-flagged-neutral { border-left-color: var(--zeal-blue); }
.zt-flagged-flat.zt-flagged-grey    { border-left-color: var(--zeal-silver); }
.zt-flagged-flat.zt-flagged-happy   { border-left-color: var(--zeal-green); }

/* ROOMY — the legacy's 1rem padding for a row with detail lines under the name. (The width cap lives on
   .zt-flagged now, so EVERY flagged row is the same width whether or not it is roomy.) */
.zt-flagged-roomy { padding: 1rem; }

/* ── SUMMARY ROW ────────────────────────────────────────────────────────────────────────────────────────
   A flagged row that also carries BUTTONS — the charges-summary categories. Wider than a plain row because
   the actions column has to sit beside the figures rather than under them, and the icon is bigger because
   this row IS the headline, not an entry in a list. */
.zt-summary-row .ztmo-title { font-size: 1.02rem; }

/* ⚠️ NO AVATAR DISC HERE. .ztmo-media is a PHOTO placeholder — a dark #444 circle with a light glyph, which is
   right when a missing profile picture is the thing being stood in for. This row has no photo and never will:
   the icon IS the content, so the disc has to go or it renders as a black blob with an unreadable glyph on it.
   Background, border and clipping off; the icon then draws at full size in the row's tone colour. */
.zt-summary-row .ztmo-media {
	background: none;
	border: none;
	overflow: visible;
	width: 2.75rem;
	height: 2.75rem;
}

.zt-summary-row .ztmo-media-fallback { font-size: 2rem; }
/* THE FIGURES read as data, not as a muted caption — this is the number the row exists to show. */
.zt-summary-row .ztmo-subtitle { font-size: 0.95rem; color: var(--zeal-medium-grey); font-weight: 700; }
.zt-summary-row .ztmo-actions { gap: 0.4rem; }

/* THE ICON TAKES THE ROW'S TONE, so a category reads good/bad at a glance without parsing the number. */
.zt-flagged-happy .ztmo-media-fallback { color: var(--zeal-green); }
.zt-flagged-error .ztmo-media-fallback { color: var(--zeal-red); }
.zt-flagged-warn  .ztmo-media-fallback { color: var(--zt-gold-accent); }
.zt-flagged-neutral .ztmo-media-fallback { color: var(--zeal-blue); }
/* Grey completes the set — without it the fifth tone's glyph falls back to ZtMediaObject's #bbb, which is a
   missing-photo grey and washes out entirely on the light disc below. */
.zt-flagged-grey .ztmo-media-fallback { color: var(--zeal-silver); }

/* ── AN ICON-ONLY ROW WEARS THE HOUSE ICON DISC, NOT THE MISSING-PHOTO PLATE ──────────────────────────────
   ZtMediaObject's .ztmo-media is a dark #444 plate inside a white ring: exactly right for a member row, where
   a missing PHOTO should read as an empty frame. It is wrong for a row whose "avatar" is a subject ICON and
   never had a photo to miss — the check-ins menu (Kiosk, Scoreboard, each program), where that plate showed
   through as a near-black circle with the tone glyph lost on it (Vince, 2026-08-16: "completely dark").

   --zt-icon-disc is the SAME token ZtItemCard's leading icon uses, and it is 6% black rather than a named grey
   precisely so it reads as one soft recess over every tone tint instead of going muddy on the coloured ones.
   Matching it is what makes these rows look like every other icon row in the app.

   ⚠️ SCOPED BY :has, NOT BY A MODIFIER CLASS — the distinction is "did a picture actually render", which the
   markup already answers. A flag on the control would be a second source of truth for the same fact, and the
   sibling rule at .ztmo-media:has(.ztmo-media-img) already reads it this way. */
.zt-flagged .ztmo-media:not(:has(.ztmo-media-img)) {
	background: var(--zt-icon-disc);
	border: none;
}

/* THE DETAIL LINES inside a roomy row: the legacy's 10pt, stacked almost flush — a tight block under the name
   rather than the airier rhythm a standalone media object uses.

   ⚠️ THE SPACING IS THE CONTENT GAP MINUS .ztmo-subtitle's -0.35rem, not the gap alone. That negative margin
   exists to pull the FIRST subtitle up under the title, and it applies to every line — so setting the gap to
   1px here would OVERLAP the lines. 0.4rem netted out to a hairline (the legacy's literal 1px) and read as
   cramped on screen, so this is 0.55rem — about 3px between lines. */
.zt-flagged-roomy .ztmo-content { gap: 0.55rem; }
.zt-flagged-roomy .ztmo-subtitle { font-size: 0.84rem; }

/* ── TEXT LINK (ZtTextLink) ─────────────────────────────────────────────────────────────────────────────
   The legacy "spanlink": a clickable phrase inside a sentence. Reset to nothing when it renders as a
   <button> so the two element modes are visually identical. */
.zt-text-link {
	display: inline-flex;
	align-items: baseline;
	gap: 0.35rem;
	padding: 0;
	margin: 0;
	border: none;
	background: none;
	font: inherit;
	font-weight: 700;
	color: var(--zeal-blue);
	cursor: pointer;
	text-decoration: underline;
}

.zt-text-link:hover { text-decoration: none; }
.zt-text-link-icon { color: var(--zeal-green); }

/* THE SENTENCE THE LINK LIVES IN — smaller than body copy, because it is a footnote under a preview list. */
.zt-more-note { margin-top: 0.55rem; font-size: 0.85rem; font-weight: 700; }
.zt-more-note .zt-text-link { font-size: inherit; }

/* ── CONNECTED-ACCOUNT TRIAL CALENDAR ───────────────────────────────────────────────────────────────────
   A month grid where the days that HAVE trials are highlighted; clicking one filters the list beneath it.
   Ported from the legacy ca-calendar. Sized in relative units and capped, so the same grid works on a phone
   without a separate mobile layout — the cells shrink, the columns never wrap. */
/* PORTED VALUE-FOR-VALUE FROM THE LEGACY ConnectedAccountHome.razor.scss (.ca-calendar). The one deliberate
   deviation is VERTICAL TIGHTNESS (Vince): the legacy's 3px cell padding is dropped, since the 36px day circle
   already sets the row height.

   ⚠️ THE CIRCLE IS ON THE DAY NUMBER, NOT THE CELL. The cell is a plain flex box that only centres; every
   border, fill and ring belongs to .zt-ca-calendar-daynum. Styling the cell instead gives square tiles that
   fill the whole grid track — which is what this looked like before, and nothing like the legacy. */
.zt-ca-calendar { width: 520px; max-width: 100%; margin: 0 0 12px; }

.zt-ca-calendar-nav {
	display: flex;
	align-items: center;
	justify-content: space-between;
	padding: 2px 6px;
	margin-bottom: 4px;
}

.zt-ca-calendar-title { font-size: 18px; font-weight: 600; color: #666; }

/* THE MONTH ARROWS ARE BARE GREEN CHEVRONS, as the legacy drew them — still ZtIconButtons (house rule: buttons
   come from the library), just stripped of their button chrome so they read as the legacy's plain icons. */
.zt-ca-calendar-nav .ztbtn {
	background: none;
	border: none;
	width: auto;
	min-width: 0;
	height: auto;
	padding: 0;
	color: var(--zeal-green);
	font-size: 22px;
}

.zt-ca-calendar-nav .ztbtn .zt-fa { font-size: 22px; }
.zt-ca-calendar-nav .ztbtn:hover { filter: brightness(1.15); background: none; }
.zt-ca-calendar-nav .ztbtn:disabled, .zt-ca-calendar-nav .ztbtn.ztbtn-disabled { background: none; color: #ccc; }

.zt-ca-calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }

.zt-ca-calendar-dow { text-align: center; font-size: 12px; font-weight: 600; color: #999; padding: 2px 0; }

.zt-ca-calendar-cell { display: flex; align-items: center; justify-content: center; font-size: 15px; color: #333; }

/* HIDDEN, NOT ABSENT — the leading blanks still hold their grid track so day 1 lands under its real weekday. */
.zt-ca-calendar-empty { visibility: hidden; }

.zt-ca-calendar-daynum {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 36px;
	height: 36px;
	border-radius: 50%;
	box-sizing: border-box;
}

/* TODAY: a subtle grey ring — context, not a claim on attention. */
.zt-ca-today .zt-ca-calendar-daynum { border: 1.5px solid #bbb; }

/* DAYS WITH TRIALS: a solid green circle, and the only clickable cell on the grid. */
.zt-ca-has-class { cursor: pointer; }
.zt-ca-has-class .zt-ca-calendar-daynum { background-color: var(--zeal-green); color: var(--zeal-white); font-weight: 600; }
.zt-ca-has-class:hover .zt-ca-calendar-daynum { background-color: #1f431f; }

/* THE ACTIVE FILTER: darker green with a GOLD RING, so which day the list is showing is unmistakable. */
.zt-ca-selected .zt-ca-calendar-daynum {
	background-color: #1f431f;
	color: var(--zeal-white);
	font-weight: 600;
	outline: 3px solid var(--zeal-gold);
	outline-offset: 1px;
}

.zt-ca-calendar-caption { font-size: 12px; color: #666; margin-top: 4px; }

/* THE TRIAL / PROSPECT CARDS are the legacy's fixed 520px — the SAME width as the calendar above them, which is
   what makes the section read as one block rather than a grid and a list that happen to be stacked. */
.zt-ca-card { --zt-flagged-max: 520px; }

/* ── TRIAL / PROSPECT CARDS ON A PHONE (Vince, 2026-08-14) ──────────────────────────────────────────────
   TWO THINGS GO WRONG AT PHONE WIDTH, both about the row's width budget:

   ① THE 86px AVATAR EATS THE DETAILS. Every line to its right — the email especially — truncates to an
      ellipsis while a large picture of a placeholder avatar keeps its full size. The size is a custom
      property on .ztmo, so ONE override shrinks it everywhere on these cards without touching a call site
      (the charges header already does exactly this).

   ② THE THREE BUTTONS COME OUT THREE DIFFERENT WIDTHS. Stacked, "Waiver reminder" sits on its own line and
      Update/Home share the line under it, each sized to its own label — so the block reads as ragged. The
      reminder button now spans the full row and Update/Home split that same row equally, so all three edges
      line up. Targeted by .ztbtn-gold (the reminder is the only gold button on these cards), NOT a new class. */
@media (max-width: 599px) {
	.zt-ca-card .ztmo { --zt-media-avatar: 56px; }
	.zt-ca-card .ztmo-actions { width: 100%; }
	/* The reminder takes a whole row … */
	.zt-ca-card .ztmo-actions .ztbtn-gold { flex: 0 0 100%; justify-content: center; }
	/* … and Update/Home share the next one, so the two rows are the same total width. */
	.zt-ca-card .ztmo-actions .ztbtn:not(.ztbtn-gold) { flex: 1 1 0; justify-content: center; }
}

/* The Refresh button under each list — its own line, left-aligned like every other action in the app. */
.zt-ca-refresh { display: flex; justify-content: flex-start; margin-top: 0.9rem; }

/* ── SECTION TITLE-BAND TONES ───────────────────────────────────────────────────────────────────────────
   THE SAME FOUR-TONE VOCABULARY THE WAVE EDITORS USE, on a section's title band: green = happy (the default
   above), yellow = warning, blue = neutral, red = error. Same --zt-*-bg/-bd variables, so a tone can never
   drift between a section header and the editor that opens from it.

   ERROR, NOT WARN, FOR DISPUTES: a dispute is something that has already gone wrong and is being answered,
   not a caution about an action the user is about to take. The icon takes the colour too — a red band under
   a tiffany icon reads as a styling accident rather than a state. Faded, as with every tone here: the wash
   plus the icon carry the signal, never a saturated alarm bar. */
.zt-analytics-section.zt-section-warn > .zt-action-card-title { background: var(--zt-warn-bg); border-bottom-color: var(--zt-warn-bd); }
.zt-analytics-section.zt-section-neutral > .zt-action-card-title { background: var(--zt-neutral-bg); border-bottom-color: var(--zt-neutral-bd); }
.zt-analytics-section.zt-section-error > .zt-action-card-title { background: var(--zt-error-bg); border-bottom-color: var(--zt-error-bd); }
.zt-analytics-section.zt-section-error > .zt-action-card-title .zt-action-card-title-icon { color: var(--zeal-red); }
/* THE ICON TAKES THE TONE TOO — a yellow band under a tiffany icon reads as an accident, same as the red case. */
.zt-analytics-section.zt-section-warn > .zt-action-card-title .zt-action-card-title-icon { color: var(--zt-gold-accent); }
.zt-analytics-section.zt-section-neutral > .zt-action-card-title .zt-action-card-title-icon { color: var(--zeal-blue); }

/* ⚠️ A RED CARD KEEPS ITS ACCENT. zeroing border-top-width above is fine when every card is green (the band
   carries the identity), but on Insights the card style is INFORMATION — red means "this program has no active
   members". Without the accent that warning survives only as a red title icon, which is far too quiet. */
.zt-analytics-section.zt-action-card-red { border-top-width: 6px; }

/* ── FUNNEL STEPS ───────────────────────────────────────────────────────────────────────────────────────
   A labelled bar per stage, scaled against the BUSIEST step so the drop-off is readable at a glance. Values
   ported verbatim from the legacy ConnectedSiteAnalytics styles so the section matches it exactly. */
.zt-funnel-step {
	display: grid;
	grid-template-columns: minmax(90px, 170px) 1fr 36px;
	gap: 7px;
	align-items: center;
	padding: 2px 0;
}
.zt-funnel-step-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-funnel-step-track { background-color: #efefef; border-radius: 4px; height: 18px; overflow: hidden; }

/* THE FILL'S WIDTH IS THE ONE GENUINELY DYNAMIC VALUE HERE, so it arrives as a CSS CUSTOM PROPERTY from the
   markup (--zt-funnel-pct) rather than an inline width — inline styles are barred outside this library, and a
   custom property is the sanctioned way to pass a runtime number in. */
.zt-funnel-step-fill { background-color: rgba(75, 192, 124, 0.55); border-radius: 4px; height: 100%; width: var(--zt-funnel-pct, 0%); }
.zt-funnel-step-count { text-align: right; font-weight: bold; }

/* ── TOP PAGES ──────────────────────────────────────────────────────────────────────────────────────────
   A six-column readout, not a <table> — raw table markup is barred outside this library, and the grid gives
   the same alignment with better small-screen behaviour. Column ratios are the legacy's. */
.zt-top-page-row {
	display: grid;
	grid-template-columns: 3fr 1fr 1fr 1fr 1fr 1fr;
	gap: 7px;
	padding: 4px 0;
	align-items: center;
}
.zt-top-page-row > div { min-width: 0; }
.zt-top-page-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-top-page-header { border-bottom: 1px solid #dedede; font-weight: 700; }
.zt-top-page-header .zt-top-page-path { font-weight: 700; }

/* ── INSIGHTS / ANALYTICS BREAKDOWN ─────────────────────────────────────────────────────────────────────
   The legacy's member-summary pair was a fixed 2-up that overflowed on a phone. Two columns when there's room,
   ONE when there isn't — same breakpoint language as the rest of the file. */
/* COLUMNS HUG THEIR CONTENT. Two equal 1fr columns each took half the card, so a ~200px stat list sat in a
   ~410px track and the leftover became a canyon down the middle. max-content sizes each column to its widest
   row and justify-content:start keeps the pair together on the left, so the gap is the gap you actually set. */
.zt-insights-breakdown {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, max-content));
	justify-content: start;
	gap: 0.35rem 4rem;
	margin-top: 0.5rem;
}
@media (max-width: 599px) { .zt-insights-breakdown { grid-template-columns: 1fr; } }

/* ROW RHYTHM IS THE STANDARD ONE — deliberately NOT overridden here. An earlier attempt tightened the
   line-height, icon size and row gap in this block; it made the two columns read as cramped next to the
   funnel sections right below them, which use the ordinary content-item spacing. The horizontal gap above is
   what needed fixing, not the vertical rhythm. */

/* NO SEPARATE BUSY HOST HERE ANY MORE — the range loader is the CARD's own Working overlay (ZtActionCardChart
   forwards it to ZtActionCard), which covers plot, buttons and stats together at a fixed size. Overlaying just
   the stats covered part of the card and resized it. */

/* ── PAYOUT CARD ────────────────────────────────────────────────────────────────────────────────────────
   THE CARD IS ONE CONTINUOUS LIST — collapsed it shows the first five facts, expanded it just keeps going.
   Nothing separates the two halves visually, so they must not be spaced differently either. The notes block
   carried the generic 0.6em bottom margin while every ROW inside both blocks is spaced by --zt-item-gap (3px),
   which put a visible step between "Arrival Date" and "Gross Amount" and nowhere else. */
.zt-payout-card .zt-action-card-notes { margin-bottom: var(--zt-item-gap); }

/* ── PAYOUTS SCREEN — ONE VERTICAL RHYTHM ───────────────────────────────────────────────────────────────
   The schedule card (.ztdc) and the payout cards (.zt-charge-card) each bring their own margins, tuned for the
   screens they were built for. Stacked together here that produced THREE different gaps: header→schedule
   (0.85rem + 0.5em), schedule→first payout (0.5em) and payout→payout (1rem). A flex column with a single gap
   makes every gap identical — including the one above the first card, which is the body's own padding — and
   zeroing the children's margins stops them adding back on top of it. */
.zt-payouts-section .zt-action-card-body { display: flex; flex-direction: column; gap: 0.85rem; }
.zt-payouts-section .ztdc { margin: 0; }
.zt-payouts-section .zt-charge-card { margin-bottom: 0; }

/* NO WIDTH HERE — see the "SECTIONS NEVER SET THEIR OWN WIDTH" law above the analytics sections. */
.zt-charges-section { margin-bottom: 0.75em; }
/* The identity sits FLUSH in the band — the band supplies the padding, exactly like the CA section header. */
.zt-charges-section .zt-charges-account { margin-bottom: 0; }

/* ⚠️ THIS HEADER USED TO FORCE THE DESKTOP ROW BACK ON SMALL SCREENS, AND THAT OVERRIDE IS GONE.
   It existed because the OLD global ztmo stack was name → buttons → lines, which shoved Filters BETWEEN the name
   and "Charges"; pinning the row back was the lesser evil while there was exactly ONE small button. Both halves
   of that have changed: the global stack now puts actions LAST (see the ztmo mobile block), and Recharge and
   Payouts share this class with TWO buttons — Filters AND New — which do not fit beside a name at all. The row
   held its shape by crushing the title to "Grac…" and the subtitle to "Re…" (Vince, 2026-08-07: "the filter
   button and new button still run into the header… vertically they should be underneath the name").
   So all three headers (Charges, Recharge, Payouts) now take the house stack: name → lines → buttons on their
   own row, left-aligned like every other action row. Only the smaller avatar is still worth keeping. */
@media (max-width: 599px) {
	/* A smaller avatar buys the row the width it needs on a narrow phone. */
	.zt-charges-account { --zt-media-avatar: 54px; }
	/* (The air above the button row is GLOBAL now — see .ztmo-actions in the shared ztmo mobile block.) */
}

/* FILTER BAR — wraps on small screens rather than scrolling sideways. */
.zt-charge-filters {
	display: flex;
	flex-wrap: wrap;
	gap: 0.4rem;
	margin-bottom: 0.9rem;
}

/* ── THE CHARGE CARD'S "ACTIONS" SECTION ── shares the section-title band above it; the block below carries the
   explanation and the buttons. Buttons WRAP so a narrow screen stacks them instead of scrolling sideways, and
   they sit LEFT (house rule — action buttons are never right-aligned). */
.zt-charge-actions { padding: 0.55rem 1.3rem 0.2rem; }
.zt-charge-actions-note { font-size: 0.82rem; line-height: 1.35; color: var(--zeal-grey); margin-bottom: 0.6rem; }
/* THE DISPUTE DEADLINE NOTE. Same type treatment as the actions note, but it carries its OWN horizontal inset:
   it sits directly in the card body rather than inside .zt-charge-actions, so it has no wrapper padding to
   inherit. 1.3em matches .zt-charge-card .zt-action-card-body > .zt-content-items exactly, which is what lines
   it up with the Status / Evidence Due rows above it instead of hugging the card edge. */
.zt-charge-dispute-note { padding: 0 1.3em; margin: 0.5rem 0 0.75rem; font-size: 0.82rem; line-height: 1.35; color: var(--zeal-grey); }
/* Buttons WRAP so a narrow screen stacks them instead of scrolling sideways, and they sit LEFT (house rule —
   action buttons are never right-aligned). REFRESH is always the last child. */
.zt-charge-actions-row { display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: flex-start; }

/* ── THE FILTER WAVE EDITOR ── two short toggle groups, then search + the From/To pair. Kept TIGHT: these are
   six small buttons and three fields, so generous vertical rhythm just pushed Apply off a phone screen.
   SPACING IS NOT OURS TO SET. .zt-wave-body and its form are already flex columns with gap:var(--zt-wave-gap) —
   "the ONE consistent vertical gap between everything in the body". Adding our own margins here DOUBLED it (the
   search→dates gap was gap + margin, which is exactly why it looked bigger than the rest). So this panel adds NO
   vertical margins; it only makes the body's top/bottom padding equal that same gap, so the distance from the
   header and to the footer matches the distance between the controls. */
.zt-wave-body:has(.zt-charge-quickgrid) {
	padding-top: var(--zt-wave-gap);
	padding-bottom: var(--zt-wave-gap);
}

/* The two toggle groups sit SIDE BY SIDE — they're short rows and stacking them wasted the panel's width.
   min-width:0 so a long button label can't blow out a column. Collapses to one column on narrow screens. */
/* COLUMNS ARE CONTENT-SIZED, NOT 50/50. Quick ranges holds three tiny buttons; Charge type holds three word
   buttons and needs the room — an even split forced "Cash" onto a second line. `auto` lets ranges take only what
   it needs and hands the rest to type, and the gap is tight so the extra width goes to the buttons. */
.zt-charge-quickgrid {
	display: grid;
	grid-template-columns: auto minmax(0, 1fr);
	column-gap: 0.6rem;
	/* NO margin-bottom — the body's flex gap already separates this from the form below. row-gap only applies
	   when the two bars stack on a narrow screen, and it uses the SAME gap. */
	row-gap: var(--zt-wave-gap);
}
/* Each group is its OWN little bar — a soft grey panel with rounded corners, so the caption and its buttons read
   as one control instead of loose text floating above loose buttons. */
.zt-charge-quickgroup {
	min-width: 0;
	padding: 0.55rem 0.7rem 0.65rem;
	background: #f4f5f5;
	border: 1px solid #e8eaea;
	border-radius: 8px;
}
@media (max-width: 599px) {
	.zt-charge-quickgrid { grid-template-columns: 1fr; }
}

/* Group caption — small, uppercase and tracked out so it reads as a LABEL for the buttons under it rather than
   as body copy competing with them. */
.zt-charge-quick-label {
	font-size: 0.68rem;
	font-weight: 700;
	letter-spacing: 0.07em;
	text-transform: uppercase;
	color: var(--zeal-grey);
	margin-bottom: 0.3rem;
}

/* FROM / TO side by side on a roomy screen, STACKED on a narrow one. minmax(0,1fr) (not 1fr) so a long
   validation message can't push the grid wider than the panel. */
.zt-charge-daterange { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 1rem; }
@media (max-width: 599px) {
	.zt-charge-daterange { grid-template-columns: 1fr; }
}

/* Inside the filter editor the button row is the LAST thing in its group, so it carries no bottom margin — the
   grid's row-gap owns that spacing. (The base .zt-charge-filters margin still applies to other hosts.) */
.zt-charge-quickgroup .zt-charge-filters { margin-bottom: 0; gap: 0.35rem; }

/* TIGHTEN THE PANEL. The wave body's fields default to a roomier page rhythm; with a title, two button groups
   and three fields that pushed Apply below the fold on a phone. Trim the title and the stacked-field margins. */
/* (No .zt-charge-title override here — .zt-wave-body already zeroes its margin so the body's flex gap is the
   only spacing. Re-adding a margin-bottom double-spaced the charge and cash editors.) */
/* The wrapper div around SEARCH is transparent to layout — WITHOUT this it becomes a flex item that adds its own
   box, and any margin on it would stack on top of the form's gap. The form's gap is the only spacing. */
.zt-charge-search { margin: 0; }
/* row-gap matters only when From/To stack on a narrow screen — same gap as everything else. */
.zt-charge-daterange { row-gap: var(--zt-wave-gap); }

/* SEARCH spans the whole panel — it lines up with the two toggle bars above rather than stopping at the default
   single-field cap (--zeal-field-max), which left an odd empty half-row. */
.zt-charge-search .zt-input-container { max-width: none; }
/* ---- REVENUE SUMMARY ROWS ---------------------------------------------- */
/* Status on the left, its help + drill-through on the right. Wraps on a narrow screen so the actions drop under
   the label rather than squeezing it — the row is short, so stacking costs nothing. */
.zt-revenue-summary-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.35rem 0; }
.zt-revenue-summary-actions { display: flex; align-items: center; gap: 0.4rem; flex: 0 0 auto; }
@media (max-width: 599px) {
	.zt-revenue-summary-row { align-items: flex-start; }
}
/* ---- FIGURE GRID — THE HOUSE LAYOUT FOR COLUMNS OF ZtContentItems ------ */
/* Use .zt-figure-grid ANY time a block of ZtContentItems is laid out in columns — this is the one rhythm for
   that shape, not a revenue-only style.

   COLUMNS ARE CONTENT-SIZED, NOT 1fr. Equal fractions pinned the second column to the middle of the card no
   matter how short the figures were, which read as two unrelated lists with a canyon between them. max-content
   sizes each column to its widest row and puts one deliberate gutter between them. */
.zt-figure-grid { display: grid; grid-template-columns: repeat(2, max-content); justify-content: start; gap: 0 2.25rem; }

/* ONE ROW RHYTHM, and each row a touch shorter than a default content item — this is a dense block of figures,
   not a form. min-height + a fixed line-height keep rows WITH a label and rows WITHOUT one exactly the same
   height, so the two columns stay in step; the per-item margin goes to 0 because min-height sets the pitch. */
.zt-figure-grid .zt-content-item { gap: 6px; font-size: 0.92rem; line-height: 1.3; min-height: 1.3rem; align-items: center; }
.zt-figure-grid .zt-content-item-icon { font-size: 0.95rem; }
.zt-figure-grid .zt-content-item + .zt-content-item { margin-top: 0; }

/* ONE COLUMN on a phone, where two columns of currency wrap badly. */
@media (max-width: 599px) {
	.zt-figure-grid { grid-template-columns: 1fr; justify-content: stretch; }
}

/* THREE / TWO / ONE — the variant for a list whose LENGTH IS NOT KNOWN UP FRONT (the projection months run from
   12 to 60 depending on the range picked). Items are FLAT children here, not pre-split columns, so the column
   count is the browser's to decide.

   EXPLICIT BREAKPOINTS, NOT auto-fill. auto-fill derives the count from a minimum track width, which means the
   answer changes with whatever padding the host card happens to have — a 60-month list has to stay THREE columns
   wide on a desktop or it runs off the bottom of the screen, and that is not something to leave to arithmetic.
   These rules MUST follow the base .zt-figure-grid media query: same specificity, so source order decides.

   COLUMN-MAJOR, NOT ROW-MAJOR: grid-auto-flow:column fills DOWN a column before starting the next, so the list
   reads oldest top-left to newest bottom-right. That requires an explicit ROW COUNT — without one the grid makes
   a single row of N columns. The count is ceil(items / columns), which only the server can work out, so it
   arrives as --zt-rows-3 / -2 / -1 and each breakpoint uses the one matching its column count. */
/* ⚠️ COLUMNS ARE CONTENT-SIZED, NOT 1fr — the same correction the base .zt-figure-grid already carries, and it
   matters MORE here. Equal fractions give each column a third of the card no matter how short "2026-08: $125.49"
   is, so the three columns drift apart as the card gets wider and read as three unrelated lists with canyons
   between them (Vince: "way too much horizontal space between those columns"). max-content sizes each column to
   its own widest row and puts ONE deliberate gutter between them, so the block stays tight at any card width. */
.zt-figure-grid-auto {
	grid-template-columns: repeat(3, max-content);
	grid-template-rows: repeat(var(--zt-rows-3, 1), auto);
	grid-auto-flow: column;
	justify-content: start;
	column-gap: 1.4rem;
}

@media (max-width: 1199px) {
	.zt-figure-grid-auto {
		grid-template-columns: repeat(2, max-content);
		grid-template-rows: repeat(var(--zt-rows-2, 1), auto);
	}
}

@media (max-width: 599px) {
	.zt-figure-grid-auto {
		grid-template-columns: 1fr;
		grid-template-rows: repeat(var(--zt-rows-1, 1), auto);
	}
}
/* ---- REVENUE: PROJECTION MONTH TONES ---------------------------------- */
/* THE MONTH'S STATE LIVES ON ITS ICON: green = completed, yellow = in progress, grey = not yet happened. */
.zt-projection-past .zt-content-item-icon { color: var(--zeal-green); }
.zt-projection-current .zt-content-item-icon { color: var(--zeal-gold, #e08b2f); }
.zt-projection-future .zt-content-item-icon { color: var(--zeal-grey, #9a9a9a); }
.zt-projection-future .zt-content-item-value { color: var(--zeal-grey, #9a9a9a); }
/* ---- REVENUE: RANGE TOOLBAR + FIGURE TONES ----------------------------- */
/* THE RANGE STRIP — one tinted, rounded bar holding the buttons, as the legacy had it. Inline-flex so the strip
   hugs its buttons instead of running the full card width; wraps on a narrow screen. */
.zt-acc-ranges { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; padding: 0.4rem; margin: 0.35rem 0 0.6rem; background: #ececec; border-radius: 8px; }
/* THE LEGEND COLOURS, on the icon: money that arrived is green, a failure is red (Danger already), and pending is
   muted because it is neither good nor bad yet. */
.zt-rev-good .zt-content-item-icon,
.zt-rev-good-icon { color: var(--zeal-green); }
.zt-rev-muted .zt-content-item-icon { color: var(--zeal-grey, #7a7a7a); }
/* The two prose lines under the grid — full width, matched to the grid's icon gap so the icons above and below
   sit on one vertical line. */
.zt-revenue-note { display: flex; align-items: baseline; gap: 6px; line-height: 1.45; }

/* SPACING AROUND THE PROSE LINES, by adjacency rather than :first-of-type — the grid <div> is the first div of
   its type here, so a :first-of-type rule on the note matched nothing at all. The grid and the notes are a
   separate thought, and the two notes need room to read as two sentences rather than a wrapped one. */
.zt-figure-grid + .zt-revenue-note { margin-top: 0.65rem; }
.zt-revenue-note + .zt-revenue-note { margin-top: 0.01rem; }

/* THE RANGE BUTTONS, a touch smaller — global, so every chart toolbar matches.
   ⚠️ HEIGHT IS STATED EXPLICITLY. ZtSmallButton now takes its height from --zt-btn-compact-h (30px, shared with
   ZtIconButton so an action pair lines up); vertical padding no longer sizes it, so without this the toolbar
   would silently grow back to 30px and undo the shrink. */
.zt-acc-ranges { padding: 0.3rem; gap: 0.3rem; }
.zt-acc-ranges .ztbtn.ztbtn-small { height: 26px; padding: 0 0.6rem; font-size: 0.78rem; }

/* ---- ZT DATA TABLE (ZtDataTable) --------------------------------------- */
/* THE HOUSE "ALIGNED COLUMNS OF FIGURES" SHAPE. One grid template, handed in as --zt-dtable-cols, drives the
   header and every row beneath it — so the columns line up because they are literally the same tracks, not
   because two sets of widths were kept in step by hand.

   DENSE ON PURPOSE. These tables pack seven columns of money; the legacy toned the text down to 0.8rem for
   exactly that reason and this keeps it. */
.zt-dtable { display: block; width: 100%; font-size: 0.8rem; }

.zt-dtable-head,
.zt-dtable-row {
	display: grid;
	grid-template-columns: var(--zt-dtable-cols, 1fr);
	gap: 6px;
	align-items: center;
	padding: 3px 0;
}

/* GRID ITEMS REFUSE TO SHRINK BELOW THEIR CONTENT — min-width:0 is what lets a long account name actually
   truncate instead of blowing the row's tracks apart. */
.zt-dtable-th,
.zt-dtable-td { min-width: 0; display: flex; align-items: center; gap: 5px; }

.zt-dtable-head { border-bottom: 1px solid #dedede; font-weight: 700; }

.zt-dtable-row + .zt-dtable-row { border-top: 1px solid #f2f2f2; }

/* FIGURES RIGHT-ALIGN so the units stack under each other. */
.zt-dtable-numeric { justify-content: flex-end; text-align: right; }

.zt-dtable-truncate .zt-dtable-cell-text,
.zt-dtable-td.zt-dtable-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-dtable-cell-text { overflow: hidden; text-overflow: ellipsis; }

.zt-dtable-cell-icon { color: var(--zeal-dark-green); flex: 0 0 auto; }

/* A SORTABLE HEADING IS A BUTTON, and it has to look like the plain headings beside it — so it is stripped of
   every default a <button> brings and given a pointer plus a hover tint. */
.zt-dtable-sortable {
	appearance: none;
	background: none;
	border: 0;
	padding: 0;
	margin: 0;
	font: inherit;
	font-weight: inherit;
	color: inherit;
	cursor: pointer;
	text-align: inherit;
	min-height: 28px;              /* a finger-sized tap target even at 0.8rem text */
}

.zt-dtable-sortable:hover .zt-dtable-head-text { color: var(--zeal-dark-green); text-decoration: underline; }

/* THE SORTED COLUMN: BOLD + A GREEN CIRCLED ARROW. Two signals answering two questions — WHICH column (bold,
   carried down through the cells so it still reads among the figures) and WHICH WAY (the arrow). */
.zt-dtable-th.is-sorted .zt-dtable-head-text { color: var(--zeal-dark-green); }
.zt-dtable-td.is-sorted { font-weight: 700; }
.zt-dtable-sorticon { color: var(--zeal-green); flex: 0 0 auto; }

/* THE PHONE SORT STRIP — hidden on anything wider, where the header row is the control. */
.zt-dtable-sortbar { display: none; }

/* ---- SMALL SCREENS: THE TABLE STOPS BEING A TABLE ---------------------- */
/* Seven columns do not fit in 375px, and squeezing them produces a horizontal scroll that hides the figures
   behind the names — the one thing a league table exists to show. So each ROW becomes a small stacked card and
   every CELL wears its column heading as a label (from the data-label attribute the control writes).

   THE HEADER ROW GOES with it, and the sort strip takes its place, so sorting still works with nothing left to
   click. 700px matches .zt-figure-grid-auto's last breakpoint rather than inventing a new one. */
@media (max-width: 599px) {
	.zt-dtable-head { display: none; }

	.zt-dtable-sortbar {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		gap: 0.3rem;
		padding: 0.3rem;
		margin: 0 0 0.6rem;
		background: #ececec;
		border-radius: 8px;
	}

	.zt-dtable-sortbar-label { font-weight: 700; padding: 0 0.25rem; }

	.zt-dtable-sortbtn {
		appearance: none;
		display: inline-flex;
		align-items: center;
		gap: 0.3rem;
		min-height: 30px;             /* finger-sized */
		padding: 0 0.6rem;
		font: inherit;
		color: inherit;
		background: #fff;
		border: 1px solid #dcdcdc;
		border-radius: 6px;
		cursor: pointer;
	}

	.zt-dtable-sortbtn.is-sorted { font-weight: 700; color: var(--zeal-dark-green); border-color: var(--zt-happy-bd); background: var(--zt-happy-bg); }

	.zt-dtable-row {
		display: block;
		padding: 0.5rem 0;
		border-top: 1px solid #e6e6e6;
	}

	.zt-dtable-td {
		display: flex;
		align-items: baseline;
		justify-content: space-between;
		gap: 0.75rem;
		padding: 1px 0;
		text-align: left;
	}

	/* THE HEADING BECOMES THE LABEL. Only where there is one — a cell whose column has a blank header would
	   otherwise render an empty label and an odd gap. */
	.zt-dtable-td[data-label]:not([data-label=""])::before {
		content: attr(data-label);
		flex: 0 0 auto;
		font-weight: 700;
		color: #555;
	}

	/* THE FIRST CELL IS THE ROW'S TITLE — the account name — so it leads, unlabelled and a size up, rather than
	   reading as just another labelled field in its own card. */
	.zt-dtable-td:first-child { font-size: 0.9rem; font-weight: 700; }
	.zt-dtable-td:first-child::before { content: none; }

	/* NOTHING IS RIGHT-ALIGNED ANY MORE: the value is already pinned right by the label's space-between. */
	.zt-dtable-numeric { text-align: right; }
	.zt-dtable-truncate .zt-dtable-cell-text { white-space: normal; }
}

/* ---- ZEAL EXECUTIVE SUMMARY ------------------------------------------- */
/* THE TWO LEAD ROWS (Upcoming Demos, Contact Us) carry four detail lines and a button, so they need more width
   than ZtFlaggedItem's default 30rem cap — at that width the Update button crowds a long question. The cap is a
   custom property precisely so a row can widen itself without a second rule; max-width, not width, so a phone
   still gets the full column. */
.zt-exec-lead { --zt-flagged-max: 44rem; }

/* THE LEAD'S AVATAR IS A BIG PALE-GREEN DISC, not the photo placeholder. .ztmo-media defaults to a dark #444
   circle with a light glyph, which is right when a MISSING PROFILE PICTURE is what is being stood in for — a
   prospect who filled in a form on the website has no photo and never will, so that dark disc reads as a black
   blob rather than a person. Same reasoning as .zt-summary-row, opposite conclusion: that row drops the disc
   because its icon IS the content; here the disc is wanted, just in a colour that belongs on the page. */
.zt-exec-lead .ztmo-media {
	width: 3.25rem;
	height: 3.25rem;
	background: var(--zt-happy-bg);
	color: var(--zeal-dark-green);
	border: 1px solid var(--zt-happy-bd);
	font-size: 2.1rem;
}

/* THE TITLE ABOVE EACH 24-MONTH HISTORY GRID. The grid itself is the shared .zt-figure-grid-auto — this is only
   the caption that names which two years are being shown. */
.zt-exec-month-title { margin: 0.75rem 0 0.35rem; font-weight: 700; }

/* ---- ZT CALENDAR (month-view pick-a-day grid) -------------------------- */
/* Ported from the legacy Schedule-a-Demo / Trial-Class calendar: a compact month grid where bookable days are
   solid house-green discs, today wears a grey ring, and past days grey out. The nav chevrons are REAL buttons
   (keyboard + a11y) stripped to look like the legacy's bare icons. */
.zt-calendar { max-width: 360px; margin: 0 0 4px 0; }

.zt-calendar-nav {
	display: flex;
	align-items: center;
	justify-content: space-between;
	padding: 2px 6px;
	margin-bottom: 2px;
	font-size: 20px;
}

.zt-calendar-navbtn {
	background: none;
	border: 0;
	padding: 0;
	margin: 0;
	font-size: inherit;
	line-height: 1;
	cursor: pointer;
}
.zt-calendar-navbtn:disabled { cursor: default; }
.zt-calendar-nav-on { color: var(--zeal-green); }
.zt-calendar-nav-off { color: #cccccc; }

.zt-calendar-title { font-size: 18px; font-weight: 600; color: #666666; }

.zt-calendar-grid {
	display: grid;
	grid-template-columns: repeat(7, 1fr);
	gap: 2px;
}

.zt-calendar-dow {
	text-align: center;
	font-size: 12px;
	font-weight: 600;
	color: #999999;
	padding: 2px 0;
}

.zt-calendar-cell {
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 0;
	font-size: 14px;
	color: #333333;
}

.zt-calendar-empty { visibility: hidden; }

.zt-calendar-daynum {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 30px;
	height: 30px;
	border-radius: 50%;
	box-sizing: border-box;
}

.zt-calendar-past .zt-calendar-daynum { color: #bbbbbb; }
.zt-calendar-today .zt-calendar-daynum { border: 1.5px solid #bbbbbb; }

.zt-calendar-has-slot { cursor: pointer; }
.zt-calendar-has-slot .zt-calendar-daynum {
	background-color: var(--zeal-green);
	color: #ffffff;
	font-weight: 600;
}
.zt-calendar-has-slot:hover .zt-calendar-daynum { background-color: #1f431f; }

/* THE TIME-SLOT BUTTON ROW the day modal offers once a highlighted day is picked — wraps on a phone. */
.zt-timeslot-buttons {
	display: flex;
	flex-wrap: wrap;
	gap: 8px;
	margin-top: 5px;
}

/* ---- WAVE-EDITOR STANDALONE TEXT --------------------------------------- */
/* Step copy that stands ALONE in a wave editor's body (instructions / confirm intro / success message).
   Deliberately BIGGER than .zt-login-intro (9pt) — that size is right when instructions sit beside a form,
   but reads too small as the only thing on the panel (Vince, 2026-08-10). */
.zt-wave-intro { font-size: 11.5pt; line-height: 1.6; color: #555; margin: 0; }
.zt-wave-body .zt-wave-intro { margin: 0; }

/* ---- CONNECTED-ACCOUNT BULK EMAIL (/connected/smtp) --------------------- */
/* The sent-list toolbar: filter box + its Search button on one row; the filter grows, the button keeps its
   size. On a narrow phone the pair wraps and the filter takes the full width (mirrors .zt-member-count-tools). */
.zt-email-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; min-width: 0; }
.zt-email-toolbar .zt-email-filter { flex: 1 1 auto; max-width: none; min-width: 0; }

/* Sent emails stack TIGHT (Vince, 2026-08-10 — the card's own 0.5em margins + a list gap stacked to ~1.6rem of
   dead air). The list owns the rhythm: no gap, the card margins zeroed, one 8px seam between rows. And the item
   card's global 4px "whisper gap" reads cramped next to this page's big subjects — give the icon real room here. */
.zt-email-list { display: flex; flex-direction: column; gap: 0; }
.zt-email-list .ztic { margin: 0 0 8px; gap: 14px; max-width: none; }
.zt-email-list .ztic:last-child { margin-bottom: 0; }

/* The RECIPIENTS list — compact avatar rows (profile picture / person fallback + name over email + delete on
   the far right), the tinted-panel treatment the member cards' program panels use, at a row-height rhythm.
   THE LIST SCROLLS INSIDE ITS OWN BOX (Vince, 2026-08-10 — 300 recipients made the whole PAGE scroll "huge"):
   capped at roughly five rows, with the section's Clear button staying put underneath. */
.zt-email-recipients { display: flex; flex-direction: column; gap: 6px; margin-bottom: 0.75rem; max-height: 330px; overflow-y: auto; padding-right: 4px; }
/* ⚠️ ON THE SHARED TONE, like everything else this comment says it copies. It held the SAME two retired literals
   (#cfe3cf / #f8fcf8) the item card did, so when .zt-member-program moved to the real accent this row would have
   been the last pale-green panel left in the app. */
.zt-email-recipient { border: 1px solid var(--zeal-green); border-left: 4px solid var(--zeal-green); background: var(--zt-happy-bg); border-radius: 8px; padding: 6px 12px; min-width: 0; }
.zt-email-recipient .ztmo-title { font-size: 1rem; }
/* ⭐ ZT PERSON ROW — the recipient row generalized into its own control (ZtPersonRow); IDENTICAL chrome, so a
   host row and a recipient row are visibly the same thing. Change one, change both. */
.zt-person-row { border: 1px solid var(--zeal-green); border-left: 4px solid var(--zeal-green); background: var(--zt-happy-bg); border-radius: 8px; padding: 6px 12px; min-width: 0; }
.zt-person-row .ztmo-title { font-size: 1rem; }
/* ⭐ THE FOUR OTHER TONES (2026-08-22, for the event attendee list). Only the ACCENT and the FILL change —
   the radius, padding and 4px left tab all stay in the base rule above, so there is one definition of what
   a person row IS and four of what colour it is. border-color rewrites all four edges INCLUDING the left
   tab, which is exactly what the row-card tone system asks for: pale fill, full-strength accent. */
.zt-person-row-warn    { border-color: var(--zt-gold-accent); background: var(--zt-warn-bg); }
.zt-person-row-error   { border-color: var(--zeal-red);       background: var(--zt-error-bg); }
.zt-person-row-neutral { border-color: var(--zeal-blue);      background: var(--zt-neutral-bg); }
.zt-person-row-grey    { border-color: var(--zeal-silver);    background: var(--zt-plain-bg); }


/* The recipient-search fields — the member-search 2×2 grid, plus the two age boxes riding the same grid. */
.zt-email-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 1rem; row-gap: 0.35rem; }
.zt-email-fields .zt-input-container { margin: 0; max-width: 100%; }

/* Modal helpers: the confirm/help copy, the fine print, the loading line, and the subject line. */
.zt-email-modal-text { margin: 0 0 0.75rem; line-height: 1.55; }
.zt-email-fineprint { font-size: 9pt; color: #666666; line-height: 1.55; margin: 0 0 0.75rem; }
.zt-email-loadline { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; margin: 0.5rem 0; }
.zt-email-modal-subject { display: flex; align-items: center; gap: 0.5rem; font-size: 10pt; margin-bottom: 0.5rem; min-width: 0; }
.zt-email-modal-subject b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-email-tagrow { margin: 0.35rem 0; }

/* The delivery-details legend + per-recipient rows: GREEN = yes, grey = no, red = bounced/dropped. The list
   scrolls inside the modal; the name column ellipsizes (min-width:0) so the three icons never wrap or clip. */
.zt-email-legend { font-size: 9pt; color: #666666; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e2e2e2; display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; }
.zt-email-delivery-list { max-height: 35vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; font-size: 10pt; padding-right: 4px; }
.zt-email-delivery-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.zt-email-delivery-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-email-ic-ok { color: var(--zeal-green); font-weight: 700; }
.zt-email-ic-no { color: #cccccc; }
.zt-email-ic-bad { color: var(--zeal-red); font-weight: 700; }

/* The Body modal's viewer — the stored HTML scrolls inside its own box, never the page. */
.zt-email-bodyview { max-height: 55vh; overflow-y: auto; border: 1px solid #e2e2e2; border-radius: 8px; padding: 14px; background: #ffffff; }
.zt-email-bodyview img { max-width: 100%; height: auto; }

/* SMALL SCREENS — the search grid collapses to one column at the same breakpoint as the member search. */
@media (max-width: 599px) {
	.zt-email-fields { grid-template-columns: 1fr; }
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════════════════
   CONNECTED CHECK-INS — the menu, the per-program check-in cards board, the kiosk and the scoreboard.
   Ported looks from the legacy CheckIns / CheckInCards / CheckInKiosk / Scoreboard pages.
   ═══════════════════════════════════════════════════════════════════════════════════════════════════════════ */

/* ── the check-ins menu — a simple stack of item rows ──
   RHYTHM (the standing rule): the STACK GAP owns every space — the rows' own 0.5em margins are zeroed so the
   space between rows is the gap alone, and the flush card body's equal padding makes the space ABOVE the
   first row identical to the space BELOW the last. The arrow action is pulled out of the flow to the FAR
   RIGHT, vertically centered, so a row is exactly one icon-disc tall. */
.zt-checkins-menu { display: flex; flex-direction: column; gap: 0.5rem; }
.zt-checkins-menu .zt-program-enter { display: contents; }
/* THE ROWS ARE ZtFlaggedItem (accent tab + tint), CAPPED AT THE SAME WIDTH AS THE CONNECTED HOME'S PROSPECT
   AND TRIAL ROWS — 520px, the .zt-ca-card figure, so the two lists are one shape rather than two that happen
   to look alike (Vince, 2026-08-16: these were "full screen wide"). The row's own 4px bottom margin is zeroed
   because the flex GAP above owns the rhythm; keeping both would stack two spacings. */
.zt-checkins-menu .zt-flagged { --zt-flagged-max: 520px; margin-bottom: 0; }
/* THE ARROW PINS TO THE FAR RIGHT, VERTICALLY CENTRED — a menu row is one icon-disc tall, so the action wants
   to sit on that line rather than at the top of a content column that has only one line in it. */
.zt-checkins-menu .zt-flagged .ztmo { align-items: center; }
.zt-checkins-menu .zt-flagged .ztmo-actions { margin-left: auto; }

/* ── the cards board ── */
/* THE BOARD IS WIDER THAN THE SHELL COLUMN. .zt-shell-content caps every page at 1000px — which can never
   fit three 380px cards — so the board expands itself symmetrically up to 1280px (or the viewport minus the
   page gutters, whichever is smaller). Margin-based, NOT transform-based: a transformed ancestor would
   re-anchor the fixed-position wave overlays (promote editor) to itself instead of the viewport. */
.zt-checkin-board {
	--zt-board-w: min(1280px, calc(100vw - 2 * var(--zt-page-gutter, 16px) - 16px));
	width: var(--zt-board-w);
	max-width: none;
	margin-left: calc((100% - var(--zt-board-w)) / 2);
}
.zt-checkin-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 0.75rem; }
/* THE SEARCH LIVES IN THE TOOLBAR — inline right of the sort/filter tiles (vertically centered) on wide
   screens, with a touch more air after the Promote tile; on narrow screens the flex wrap drops it onto its
   own full-width row underneath (margin zeroed so the wrapped row stays flush left). */
.zt-checkin-toolbar .zt-checkin-filter { flex: 1 1 240px; min-width: 220px; margin-left: 8px; }
@media only screen and (max-width: 599px) { .zt-checkin-toolbar .zt-checkin-filter { margin-left: 0; } }
/* THE GRID SCALES ITSELF (the legacy grid-system behaviour): a 380px floor per card gives 3-wide on large
   screens, 2-wide as the container narrows, and 1-wide on phones. */
/* ONE GRID FOR BOTH BOARDS (CheckInMemberGrid) — the cards board AND the kiosk results. They were two
   containers that happened to match; a shared class is what keeps them matching. */
/* 📱🔀 THE CARD FLOOR IS 340px SO AN UNFOLDED FOLDABLE GETS TWO COLUMNS.
   Vince's Galaxy Z Fold opens to ~750 CSS px and was landing on ONE column, while the legacy gave it two. The
   old 380px floor was the reason: the board is the viewport minus its gutters (~710px there), and two 380s
   plus the gap need 767 — just over. At 340 they need 687, so the fold gains its second column, a phone still
   gets one, and a desktop still gets three (three 340s + gaps = 1034, well inside the 1280 board).
   THIS IS A "HOW MUCH ROOM" QUESTION, so it is a width rule — see CLAUDE.md §7a. */
.zt-checkin-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 7px; }
/* ⚠️ THE HARD SINGLE COLUMN NOW LANDS AT THE HOUSE 480px BREAKPOINT, NOT 700px. Above it, auto-fill already
   works out the column count on its own — the old 700px override was overruling that and forcing one column
   across the entire 480–700 band AND the fold. Below it the override still earns its place: a 375px phone has
   less room than the 340px floor, and without it the track would overflow the screen. */
@media only screen and (max-width: 599px) { .zt-checkin-list { grid-template-columns: 1fr; } }
/* the empty state is not a card — it spans the whole grid rather than sitting in column one */
.zt-checkin-list .zt-empty { grid-column: 1 / -1; }

/* ── one member card — the CARD carries the check-in state like the legacy (grey = out, green = in); the
      identity header band goes transparent over it, and the buttons sit in their own inset grey toolbar. ── */
.zt-checkin-card { position: relative; margin: 0; background: #e7e7e7; }
.zt-checkin-card.is-in { background: #5cb85c; }
.zt-checkin-card > .zt-action-card-body { display: none; }
.zt-checkin-card .zt-action-card-header { background: transparent; border-bottom: none; }
.zt-checkin-card.is-in .ztmo-title,
.zt-checkin-card.is-in .ztmo-subtitle,
.zt-checkin-card.is-in .ztmo-subtitle-text,
.zt-checkin-card.is-in .ztmo-line-icon,
.zt-checkin-card.is-in .ztmo-media-actions .ztbtn { color: #ffffff; }

/* THE AVATAR RING — thin grey, not the fat white halo (Vince, 2026-08-10). */
.zt-checkin-card .ztmo-media { border: 2px solid #cfcfcf; }

/* THE NAME — its own row, a touch lighter than the library's 700, and every freed pixel goes to it. */
.zt-checkin-card .ztmo-title { font-weight: 600; font-size: 1.15rem; }

/* THE DETAIL LINES — stepped up from the library's 0.82rem; the board is a read-at-a-glance screen. */
.zt-checkin-card .ztmo-subtitle { font-size: 1.05rem; }
.zt-checkin-card .ztmo-line-icon { font-size: 1.15rem; }

/* THE BUTTON TOOLBAR — the legacy's own little grey bar, INSET from the card edges (not a full-bleed band),
   with one uniform gap between uniform tiles. */
.zt-checkin-card .zt-action-card-footer {
	display: flex;
	flex-wrap: wrap;
	align-items: stretch;
	gap: 6px;
	margin: 0 10px 10px;
	padding: 7px 8px;
	background: #bbbbbb;
	border-top: none;
	border-radius: 6px;
}
.zt-checkin-card .zt-action-card-footer .ztbtn { margin: 0; }

/* GOLD accent (top + left) = eligible for promotion; RED accent (bottom + right) = errors/warnings. Drawn on
   an overlay so the card's own drop shadow is untouched (the legacy combined them in one box-shadow). */
.zt-checkin-card.is-promo::after,
.zt-checkin-card.is-errors::after {
	content: "";
	position: absolute;
	inset: 0;
	pointer-events: none;
	border-radius: inherit;
	z-index: 1;
}
.zt-checkin-card.is-promo::after { box-shadow: inset 4px 0 0 0 #e0b400, inset 0 4px 0 0 #e0b400; }
.zt-checkin-card.is-errors::after { box-shadow: inset -4px 0 0 0 #c9302c, inset 0 -4px 0 0 #c9302c; }
.zt-checkin-card.is-promo.is-errors::after {
	box-shadow: inset 4px 0 0 0 #e0b400, inset 0 4px 0 0 #e0b400, inset -4px 0 0 0 #c9302c, inset 0 -4px 0 0 #c9302c;
}

/* ── the member-details wave editor (ZtMemberDetailsEditor) — identity + phones + addresses ── */
/* the Edit / Phone / Address trio sits in its OWN inset grey toolbar (the check-in card footer treatment). */
.zt-mdet-actions { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 0.5rem; padding: 7px 8px; background: #bbbbbb; border-radius: 6px; }
.zt-mdet-section { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; font-weight: 700; font-size: 1.05rem; color: #333333; }
.zt-mdet-section .zt-fa { color: var(--zeal-tiffany); }
/* the phone/address item cards stack on the STACK-GAP rhythm — zero their own margins, full width; the row's
   EDIT action pins to the far right (vertically centered) like the check-ins menu rows, and the icon disc
   steps up a size. */
.zt-mdet-list { display: flex; flex-direction: column; gap: 0.5rem; }
.zt-mdet-list .ztic { position: relative; margin-top: 0; margin-bottom: 0; max-width: none; align-items: center; gap: 12px; }
.zt-mdet-list .ztic-icon { font-size: 1.6rem; }
.zt-mdet-list .ztic-actions { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); margin-top: 0; }
.zt-mdet-list .ztic-body { padding-right: 52px; }

/* ══ THE KIOSK STAGE (ZtKioskStage) ═══════════════════════════════════════════════════════════════════════
   The full-viewport, dark, wall-tablet surface — the legacy kiosk's flat #333333 page, rebuilt as a library
   control. Two shapes from one stylesheet: the HERO (a lobby screen showing one big brand mark over one big
   code box, everything vertically centred) and the COMPACT working screen (a small brand strip up top so the
   card board owns the viewport).

   IT SITS UNDER THE WAVE EDITORS ON PURPOSE (9300 < .zt-wave-overlay's 9500): a promote/details editor opened
   from a kiosk card must cover the kiosk, never the other way round. Page busy (9999) and toasts stay above
   everything, as they must. */
.zt-kiosk {
	position: fixed; left: 0; right: 0; z-index: 9300; box-sizing: border-box;
	top: var(--vv-top, 0px);
	height: 100vh; height: 100dvh; height: var(--vv-height, 100dvh);
	display: flex; flex-direction: column;
	overflow-y: auto; -webkit-overflow-scrolling: touch;
	/* The legacy kiosk was a flat grey rectangle. Same family (#333333), with a soft glow behind the brand and
	   a darker floor — on a 55" lobby screen a flat fill reads as "the monitor is broken". */
	background: radial-gradient(125% 85% at 50% 0%, #3f3f3f 0%, #313131 45%, #222222 100%), #333333;
	color: #ffffff;
	font-family: var(--zt-font);
	/* body.zt-wave-open sets .zt-shell-page (this stage's ancestor) to visibility:hidden — re-assert, exactly
	   as .zt-wave-overlay does and for exactly the same reason. */
	visibility: visible;
	pointer-events: auto;
	padding:
		calc(env(safe-area-inset-top, 0px) + 1.25rem)
		calc(env(safe-area-inset-right, 0px) + var(--zt-page-gutter, 16px))
		calc(env(safe-area-inset-bottom, 0px) + 1.25rem)
		calc(env(safe-area-inset-left, 0px) + var(--zt-page-gutter, 16px));
}
/* STACKED UNDER SOMETHING ELSE (ztWave) — hidden, same contract as the wave overlays. */
.zt-kiosk.zt-wave-under { visibility: hidden; pointer-events: none; }

.zt-kiosk-inner { width: min(1280px, 100%); display: flex; flex-direction: column; min-width: 0; }
/* THE HERO IS ONE BLOCK — mark + code box + slots + hint — CENTRED ON BOTH AXES.
   ⚠️ auto margins, NOT justify-content: center. They look identical until the block is taller than the
   viewport (a short lobby screen, a landscape phone), and then a centred flex child has its TOP clipped and
   scrolled out of reach, while auto margins simply collapse to zero and the block scrolls from its top.
   The gap between the mark and the code box is deliberately tight (Vince, 2026-08-15) — they are one object,
   and the air between them was reading as two. */
.zt-kiosk:not(.zt-kiosk-compact) .zt-kiosk-inner { margin: auto; flex: 0 0 auto; gap: clamp(0.6rem, 2vh, 1.35rem); }
.zt-kiosk-compact .zt-kiosk-inner { margin: 0 auto; flex: 1 0 auto; gap: 0.85rem; }

/* THE WAY OUT — pinned to the stage's corner, not to the content, so it stays put while the board scrolls. */
.zt-kiosk-actions { position: absolute; z-index: 2; display: flex; gap: 0.4rem;
	top: calc(env(safe-area-inset-top, 0px) + 0.85rem);
	right: calc(env(safe-area-inset-right, 0px) + var(--zt-page-gutter, 16px)); }

/* ── THE BRAND MARK — the connected account's logo when it has one, the Zeal wordmark when it doesn't ──
   Both images are rendered; CSS decides which shows, so a broken logo URL swaps to the Zeal mark with no
   server round trip (the graceful-media pattern used by ZtMediaObject / ZtProfileHeader). */
.zt-kiosk-brand { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; min-width: 0; }
/* THE ACCOUNT'S MARK — a CIRCULAR CLIPPING MASK, the same shape the home page's profile avatar uses. An
   account logo is nearly always round art on a square WHITE canvas; laid flat on the dark stage that canvas
   reads as a white box behind the logo. Cover-cropping it into a disc removes the canvas rather than framing
   it, so there is deliberately NO border here — only a soft shadow to lift it off the stage. */
.zt-kiosk-mark {
	flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
	/* HALF AGAIN AS BIG as the first pass (Vince, 2026-08-15) — on a wall this is the thing you see first. */
	width: clamp(168px, 28.5vh, 285px); height: clamp(168px, 28.5vh, 285px);
	border-radius: 50%; overflow: hidden;
	box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.zt-kiosk-mark-img { display: block; width: 100%; height: 100%; object-fit: cover; }
.zt-kiosk-logo {
	display: block; object-fit: contain;
	height: clamp(72px, 13vh, 130px); max-width: min(560px, 84vw);
	filter: drop-shadow(0 10px 26px rgba(0, 0, 0, 0.45));
}
.zt-kiosk-logo-zeal { display: none; }
.zt-kiosk-brand.no-logo .zt-kiosk-logo-zeal,
.zt-kiosk-brand.logo-failed .zt-kiosk-logo-zeal { display: block; }
.zt-kiosk-brand.logo-failed .zt-kiosk-mark { display: none; }
/* THE ACCOUNT NAME ONLY RIDES WITH THE FALLBACK MARK. With a real logo the name is already in the artwork,
   and printing it again under it just says the same thing twice. */
.zt-kiosk-brand-name { display: none; font-size: clamp(1rem, 2.2vw, 1.35rem); font-weight: 600;
	letter-spacing: 0.16em; text-transform: uppercase; color: rgba(255, 255, 255, 0.72); text-align: center; }
.zt-kiosk-brand.no-logo .zt-kiosk-brand-name,
.zt-kiosk-brand.logo-failed .zt-kiosk-brand-name { display: block; }

/* COMPACT — the brand becomes a strip: mark on the left, name beside it, out of the board's way. */
.zt-kiosk-compact .zt-kiosk-brand { flex-direction: row; align-items: center; justify-content: flex-start; gap: 0.7rem; }
.zt-kiosk-compact .zt-kiosk-logo { height: clamp(34px, 6.5vh, 54px); max-width: min(260px, 55vw); filter: none; }
.zt-kiosk-compact .zt-kiosk-mark { width: clamp(38px, 7vh, 56px); height: clamp(38px, 7vh, 56px); box-shadow: none; }
.zt-kiosk-compact .zt-kiosk-brand-name { font-size: 0.85rem; letter-spacing: 0.14em; }

/* THE TOOLBAR ROW — same rhythm as the cards board's toolbar, so the kiosk's sort tiles and the board's are
   one layout rather than two that happen to look alike. */
.zt-kiosk-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.zt-kiosk-content { width: 100%; min-width: 0; }
.zt-kiosk-compact .zt-kiosk-content { flex: 1 1 auto; }
/* the shared empty state, restated for the dark stage (its light card would be invisible here) */
.zt-kiosk .zt-empty { background: rgba(255, 255, 255, 0.06); border-left-color: rgba(255, 255, 255, 0.35); }
.zt-kiosk .zt-empty-icon { color: rgba(255, 255, 255, 0.7); }
.zt-kiosk .zt-empty-title { color: #ffffff; }
.zt-kiosk .zt-empty-message { color: rgba(255, 255, 255, 0.72); }

/* ── THE KIOSK COUNTDOWN (ZtKioskCountdown) ───────────────────────────────────────────────────────────────
   Bottom-right of the working screen: "please check in and click Back", with the seconds visibly running out.
   Pinned to the VIEWPORT (not the stage's scrolling content) so it stays put however far down the board goes,
   and above the stage's own z-index — but still under the wave editors, so a promote/details editor covers it
   like it covers everything else on the kiosk. */
.zt-kcount {
	position: fixed; z-index: 9350;
	/* THE SAME GAP ON BOTH EDGES — it sits in a corner, and an even inset is the only thing that reads as
	   deliberate there (Vince, 2026-08-16: the bottom was right, the right-hand side was too far over).
	   ⚠️ ONE CASE WORTH KNOWING: this is fixed to the VIEWPORT while .zt-kiosk carries its own vertical
	   scrollbar, so on a board long enough to scroll, that scrollbar shares this right-hand strip. The old
	   value cleared it entirely, which is exactly what read as too much air on every board that fits. */
	bottom: calc(env(safe-area-inset-bottom, 0px) + 0.5rem);
	right: calc(env(safe-area-inset-right, 0px) + 0.5rem);
	display: flex; align-items: center; gap: 0.75rem;
	width: min(340px, calc(100vw - 2 * var(--zt-page-gutter, 16px)));
	box-sizing: border-box;
	padding: 0.7rem 0.9rem 0.85rem;
	border-radius: 10px;
	background: rgba(20, 20, 20, 0.92);
	border: 1px solid rgba(255, 255, 255, 0.12);
	border-left: 4px solid var(--zeal-gold);
	box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
	color: #ffffff; font-family: var(--zt-font);
	--zt-kcount-p: 100%;
}
.zt-kcount-icon { flex: 0 0 auto; font-size: 1.6rem; color: var(--zeal-gold); }
.zt-kcount-body { min-width: 0; flex: 1 1 auto; }
.zt-kcount-msg { font-size: 0.95rem; font-weight: 700; line-height: 1.25; }
.zt-kcount-sub { font-size: 0.85rem; color: rgba(255, 255, 255, 0.7); margin-top: 0.15rem; }
/* THE NUMBER IS THE POINT — big, gold, and tabular so it doesn't jitter as it counts 20 → 9. */
.zt-kcount-num { font-size: 1.35rem; font-weight: 800; color: var(--zeal-gold);
	font-variant-numeric: tabular-nums; margin-left: 0.15rem; }
/* THE BAR DRAINS ALONG THE BOTTOM EDGE, inside the card's rounded corners. Width comes from --zt-kcount-p,
   which ztKioskIdle sets — the only thing JS touches besides the number and the urgent class. */
.zt-kcount-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px;
	border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; overflow: hidden;
	background: rgba(255, 255, 255, 0.1); }
.zt-kcount-bar-fill { display: block; height: 100%; width: var(--zt-kcount-p, 100%);
	background: var(--zeal-gold); transition: width 0.25s linear; }
/* THE LAST FIVE SECONDS GO RED — a nudge, not an alarm: nothing is lost when it runs out. */
.zt-kcount.is-urgent { border-left-color: var(--zeal-red); }
.zt-kcount.is-urgent .zt-kcount-icon,
.zt-kcount.is-urgent .zt-kcount-num { color: var(--zeal-red); }
.zt-kcount.is-urgent .zt-kcount-bar-fill { background: var(--zeal-red); }
/* ON A PHONE it spans the width at the foot of the screen rather than floating in a corner it would crowd. */
@media only screen and (max-width: 599px) {
	.zt-kcount { left: calc(env(safe-area-inset-left, 0px) + var(--zt-page-gutter, 16px)); width: auto; }
}

/* ── THE KIOSK CODE FIELD (ZtKioskCodeInput) ──────────────────────────────────────────────────────────────
   The legacy's grey label band over a white field with a leading QR glyph and huge bold characters — the one
   thing on the entry screen, so it is sized to be read and hit from across a lobby. */
.zt-kcode { display: flex; flex-direction: column; align-items: center; gap: 0.95rem; width: 100%; }
.zt-kcode-shell {
	width: min(520px, 92vw); border-radius: 10px; overflow: hidden; background: #ffffff;
	box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.zt-kcode-header {
	display: flex; align-items: center; gap: 0.6rem; padding: 0.5rem 0.85rem;
	background: #6f6f6f; color: #ffffff;
	font-size: 0.9rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
}
.zt-kcode-header-icon { font-size: 1rem; opacity: 0.85; }
.zt-kcode-header-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zt-kcode-field { display: flex; align-items: center; gap: 0.65rem; padding: 0.5rem 0.7rem; background: #ffffff; }
.zt-kcode-field-icon { flex: 0 0 auto; font-size: clamp(1.5rem, 4.5vw, 2rem); color: #c2c2c2; }
.zt-kcode-input {
	flex: 1 1 auto; min-width: 0; width: 100%;
	border: 0; outline: 0; background: transparent; box-shadow: none;
	font-family: var(--zt-font); font-size: clamp(1.75rem, 6.5vw, 2.75rem); font-weight: 800;
	letter-spacing: 0.12em; text-transform: uppercase; color: #2b2b2b; padding: 0.1rem 0;
}
.zt-kcode-input::placeholder { color: #d2d2d2; font-weight: 600; letter-spacing: 0.08em; }
.zt-kcode-clear {
	flex: 0 0 auto; border: 0; background: transparent; cursor: pointer; padding: 0.15rem 0.2rem; line-height: 1;
	font-size: clamp(1.4rem, 4vw, 1.85rem); color: #bdbdbd;
}
.zt-kcode-clear:hover { color: var(--zeal-red, #c9302c); }
/* THE SLOT STRIP — how many of the expected characters have landed. The only progress signal a kiosk user
   gets, since they are usually holding a card rather than watching the field. */
.zt-kcode-slots { display: flex; gap: 0.5rem; }
.zt-kcode-slot { width: clamp(18px, 4vw, 30px); height: 5px; border-radius: 999px;
	background: rgba(255, 255, 255, 0.16); transition: background 0.18s ease, transform 0.18s ease; }
.zt-kcode-slot.is-filled { background: var(--zeal-gold); transform: scaleY(1.4); }
/* THE HINT STAYS ON ONE LINE (Vince, 2026-08-15) — it is one short instruction, and wrapping it turned a
   caption into a paragraph. It shrinks with the viewport instead of breaking: at the 0.72rem floor the full
   sentence still measures under 280px, so it holds its line down to a 320px phone. */
.zt-kcode-hint { color: rgba(255, 255, 255, 0.6); font-size: clamp(0.72rem, 2.4vw, 0.95rem);
	text-align: center; white-space: nowrap; max-width: 100%; }

/* SHORT SCREENS (a landscape phone / a tiny wall tablet) — the hero has to give the code box its room back.
   The mark drops out of its clamp here too: 28.5vh of a 500px-tall screen is fine, but the 168px floor is not. */
@media only screen and (max-height: 560px) {
	.zt-kiosk:not(.zt-kiosk-compact) .zt-kiosk-inner { gap: 0.6rem; }
	.zt-kiosk-logo { height: clamp(48px, 11vh, 76px); }
	.zt-kiosk-mark { width: clamp(84px, 20vh, 130px); height: clamp(84px, 20vh, 130px); }
	.zt-kcode-slots { display: none; }
}

/* ── the attendance scoreboard — the dark, high-contrast "big TV" leaderboard, ported 1:1 ── */
.zt-sb { background: #14161c; color: #f3f4f6; border-radius: 10px; padding: 1.5rem 1.75rem 2rem; min-height: 78vh; box-sizing: border-box; }
.zt-sb:fullscreen { border-radius: 0; min-height: 100vh; overflow-y: auto; padding: 2rem 2.5rem; }
.zt-sb-header { display: flex; flex-direction: column; gap: 1rem; padding-bottom: 1rem; margin-bottom: 1.1rem; border-bottom: 1px solid #262a33; }
.zt-sb-topbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem 1.25rem; }
.zt-sb-title { display: flex; align-items: center; gap: 0.6rem; font-size: 2.1rem; font-weight: 800; letter-spacing: 0.04em; }
.zt-sb-title .zt-fa { color: #e7b400; }
.zt-sb-actions { display: flex; gap: 0.5rem; align-items: center; }
.zt-sb-filters { display: flex; gap: 0.9rem; flex-wrap: wrap; align-items: flex-end; }
.zt-sb-filters .zt-input-container { width: 230px; max-width: 100%; margin: 0; }
.zt-sb-list { display: flex; flex-direction: column; gap: 0.5rem; }
.zt-sb-row { display: grid; grid-template-columns: 56px minmax(0, 1.4fr) minmax(0, 2fr) auto; align-items: center; gap: 1.1rem; background: #1e2229; border-radius: 8px; padding: 0.7rem 1.1rem; }
.zt-sb-rank { width: 48px; height: 48px; line-height: 48px; text-align: center; font-size: 1.55rem; font-weight: 800; color: #9aa0aa; background: #262b34; border-radius: 50%; }
.zt-sb-member { min-width: 0; }
.zt-sb-name { font-size: 1.45rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-sb-sub { display: flex; gap: 0.65rem; align-items: center; font-size: 0.95rem; color: #98a0ab; margin-top: 0.1rem; }
.zt-sb-belt { color: #cdd3db; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zt-sb-bar { height: 16px; background: #262b34; border-radius: 8px; overflow: hidden; }
.zt-sb-bar-fill { height: 100%; width: var(--zt-sb-val, 0%); background: linear-gradient(90deg, #2f8bff, #7cc0ff); border-radius: 8px; }
.zt-sb-count { font-size: 2rem; font-weight: 800; color: #f3f4f6; text-align: right; min-width: 2.5ch; font-variant-numeric: tabular-nums; }

/* top 3 — gold / silver / bronze (leaderboard sort only) */
.zt-sb-top-1 { box-shadow: inset 5px 0 0 0 #e7b400; background: #23241d; }
.zt-sb-top-2 { box-shadow: inset 5px 0 0 0 #b9bdc4; }
.zt-sb-top-3 { box-shadow: inset 5px 0 0 0 #c07a34; }
.zt-sb-top-1 .zt-sb-rank { background: linear-gradient(135deg, #ffe27a, #e0a400); color: #3a2a00; }
.zt-sb-top-2 .zt-sb-rank { background: linear-gradient(135deg, #eef0f3, #a9adb5); color: #24262b; }
.zt-sb-top-3 .zt-sb-rank { background: linear-gradient(135deg, #e6a869, #b06a2c); color: #2a1600; }
.zt-sb-top-1 .zt-sb-count { color: #ffd24a; }
.zt-sb-top-1 .zt-sb-bar-fill { background: linear-gradient(90deg, #e0a400, #ffe27a); }
.zt-sb-top-2 .zt-sb-count { color: #dfe3e8; }
.zt-sb-top-2 .zt-sb-bar-fill { background: linear-gradient(90deg, #a9adb5, #eef0f3); }
.zt-sb-top-3 .zt-sb-count { color: #e6a869; }
.zt-sb-top-3 .zt-sb-bar-fill { background: linear-gradient(90deg, #b06a2c, #e6a869); }

/* graceful "no data" panel */
.zt-sb-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 0.5rem; margin: 1.75rem auto 0; max-width: 520px; padding: 2.75rem 2rem; background: #1a1e25; border: 1px solid #262a33; border-radius: 12px; color: #c7ccd4; }
.zt-sb-empty-icon { font-size: 2.4rem; color: #6b7280; margin-bottom: 0.25rem; }
.zt-sb-empty-title { font-size: 1.4rem; font-weight: 700; color: #e8eaee; letter-spacing: 0.02em; }
.zt-sb-empty-sub { font-size: 1rem; color: #98a0ab; }

/* responsive: drop the bar column on narrow screens */
@media only screen and (max-width: 839px) {
	.zt-sb { padding: 1rem 1rem 1.5rem; }
	.zt-sb-title { font-size: 1.5rem; }
	.zt-sb-row { grid-template-columns: 44px minmax(0, 1fr) auto; gap: 0.75rem; }
	.zt-sb-bar { display: none; }
	.zt-sb-rank { width: 40px; height: 40px; line-height: 40px; font-size: 1.25rem; }
	.zt-sb-name { font-size: 1.2rem; }
	.zt-sb-count { font-size: 1.6rem; }
}

/* SMS "Text Message" body modal — plain text (not HTML), so whitespace and line breaks render as typed. */
.zt-sms-body-box { max-height: 55vh; overflow-y: auto; border: 1px solid #e2e2e2; border-radius: 8px; padding: 14px; background: #ffffff; white-space: pre-wrap; overflow-wrap: break-word; font-size: 10.5pt; line-height: 1.5; }

/* ---- WAVE DANGER NOTE + DUPLICATE LIST -----------------------------------------------------------------
   .zt-wave-danger is the "you cannot take this back" line — deliberately LOUDER than .zt-wave-fineprint
   (which is quiet grey small print). Bold + red, its own paragraph, never inline with the explanation. */
.zt-wave-danger {
	color: #c0392b;
	font-weight: 700;
	margin: 0.85rem 0 0;
}

/* THE FILES A DUPLICATE UPLOAD WOULD REPEAT — a plain, scannable list, capped by the caller.
   TWO THINGS THIS HAS TO FIGHT (both bit it on the first pass):
     1. .zt-wave-body IS A FLEX COLUMN, so a plain <ul> shrinks to its content width and centres itself —
        it must align-self:stretch to sit flush with the intro text and the option picker above/below it.
     2. The album renders inside the SITE CANVAS, whose stylesheet decorates list items with big check
        bullets; killing list-style + the pseudo-elements keeps this a quiet file list, not a feature list.
   Doubled class so it outranks those canvas rules wherever the wave ends up in the tree. */
.zt-dup-list.zt-dup-list {
	align-self: stretch;
	width: 100%;
	box-sizing: border-box;
	margin: 0 0 1rem;
	padding: 0.45rem 0.7rem;
	list-style: none;
	max-height: 22vh;
	overflow-y: auto;
	background-color: rgba(0, 0, 0, 0.035);
	border-radius: 6px;
	color: #666666;
	font-size: 0.82rem;
	line-height: 1.35;
	text-align: left;
}

.zt-dup-list.zt-dup-list li {
	display: flex;
	align-items: center;
	gap: 0.45rem;
	list-style: none;
	margin: 0;
	padding: 0.1rem 0;
	font-size: inherit;
	line-height: inherit;
}

.zt-dup-list.zt-dup-list li::before,
.zt-dup-list.zt-dup-list li::after { content: none !important; }

/* OUR OWN PER-FILE ICON — gold to match the warning wave, sized to the row so it reads as a bullet
   rather than an illustration (the canvas's inherited check bullets were the thing being replaced). */
.zt-dup-list.zt-dup-list .zt-dup-icon {
	flex: 0 0 auto;
	color: #c9a227;
	font-size: 0.95em;
}

.zt-dup-list.zt-dup-list .zt-dup-name { min-width: 0; word-break: break-all; }

.zt-dup-list.zt-dup-list .zt-dup-more { color: #888888; font-style: italic; }
.zt-dup-list.zt-dup-list .zt-dup-more .zt-dup-icon { color: #999999; }
/* ---- NON-DESTRUCTIVE PHOTO CROP (display only) ---------------------------------------------------------
   The stored file is never modified — the album item carries a normalized rectangle and the picture is
   framed HERE.  The wrapper shows exactly the crop; the image inside is scaled by 1/CropW and shifted by
   -CropX/CropW so the chosen region fills it.  Percentages are resolution-independent, so the very same
   values work on the full image and on the small thumbnail.
   Custom properties come from ZtAlbumItem.CropStyle. */
.zt-crop {
	position: relative;
	width: 100%;
	aspect-ratio: var(--zt-crop-ar, 1);
	overflow: hidden;
	border-radius: inherit;
}

/* ⚠️ THE CUSTOM PROPERTIES ARE UNITLESS FRACTIONS — calc() cannot divide by a percentage, and this
   divides by the crop size to scale the image up so the chosen region fills the wrapper. */
.zt-crop > img {
	position: absolute;
	width: calc(100% / var(--zt-crop-w, 1));
	height: auto;
	left: calc(-100% * var(--zt-crop-x, 0) / var(--zt-crop-w, 1));
	top: calc(-100% * var(--zt-crop-y, 0) / var(--zt-crop-h, 1));
	max-width: none;
}
/* =========================================================================================================
   ALBUM CSS ARMOR — "our albums look the same on every site"  (Vince, 2026-08-13)

   Albums render INSIDE the site canvas (.layout-container), so the site's own stylesheet — the default
   connected-site sheet, a template, or an IMPORTED site's theme blob — is in the cascade with them.  Two
   real bugs came from exactly that today: the duplicate list inherited big check-mark bullets, and
   `.layout-container p img { object-fit: contain }` quietly defeated edge-drag resizing.

   WHY THE SPECIFICITY LADDER: a theme blob is LINKED AFTER zt-ui.css, so on a specificity TIE it wins.
   Its rules are typically (0,2,1) — e.g. `.layout-container p img`.  Repeating the scope class here gets
   us to (0,3,x), which outranks them without resorting to !important on layout properties (that would
   also block our own components).  Anything we genuinely never want overridden is neutralized outright.

   USAGE: put `zt-album-scope` on the ROOT of every album surface (grid page, albums index, lightbox,
   comments).  Everything below is inert outside that scope.
   ========================================================================================================= */

/* ---- 1. THE BOUNDARY: stop inherited typography at the album's edge ---- */
.zt-album-scope.zt-album-scope {
	font-family: var(--zt-font, "Inter", system-ui, -apple-system, "Segoe UI", sans-serif);
	font-size: 1rem;
	font-weight: 400;
	font-style: normal;
	line-height: 1.5;
	letter-spacing: normal;
	word-spacing: normal;
	text-transform: none;
	text-align: left;
	text-shadow: none;
	color: #f2f2f2;
	box-sizing: border-box;
}

.zt-album-scope.zt-album-scope *,
.zt-album-scope.zt-album-scope *::before,
.zt-album-scope.zt-album-scope *::after { box-sizing: border-box; }

/* ---- 2. LISTS: templates love turning these into decorated feature lists ---- */
.zt-album-scope.zt-album-scope.zt-album-scope ul,
.zt-album-scope.zt-album-scope.zt-album-scope ol,
.zt-album-scope.zt-album-scope.zt-album-scope li {
	list-style: none;
	margin: 0;
	padding: 0;
	background: none;
	text-indent: 0;
}

.zt-album-scope.zt-album-scope.zt-album-scope li::before,
.zt-album-scope.zt-album-scope.zt-album-scope li::after { content: none !important; }

/* ---- 3. TEXT ELEMENTS: no template margins, drop caps, rules or decoration ---- */
.zt-album-scope.zt-album-scope.zt-album-scope p,
.zt-album-scope.zt-album-scope.zt-album-scope h1,
.zt-album-scope.zt-album-scope.zt-album-scope h2,
.zt-album-scope.zt-album-scope.zt-album-scope h3,
.zt-album-scope.zt-album-scope.zt-album-scope h4,
.zt-album-scope.zt-album-scope.zt-album-scope h5,
.zt-album-scope.zt-album-scope.zt-album-scope h6,

/* ⛔ NEVER RE-FONT AN ICON.  ZtIcon renders `<span class="zt-fa …">` and delivers the glyph through a
   ::before/::after codepoint in the Font Awesome webfont — so `font-family: inherit` on spans (which the
   first version of this armour did) strips the icon font and every icon renders as a tofu box showing the
   glyph's NAME.  .zt-fa is excluded from the span reset above for exactly that reason: it must keep the
   font-family its own class assigns.  Do not add .zt-fa to any font-family rule here. */
.zt-album-scope.zt-album-scope.zt-album-scope span:not(.zt-fa),
.zt-album-scope.zt-album-scope.zt-album-scope b {
	margin: 0;
	padding: 0;
	border: 0;
	background: none;
	float: none;
	text-transform: none;
	letter-spacing: normal;
	font-family: inherit;
}

.zt-album-scope.zt-album-scope.zt-album-scope p::first-letter { font-size: inherit; float: none; }

.zt-album-scope.zt-album-scope.zt-album-scope a {
	background: none;
	text-decoration: none;
	border: 0;
	font-family: inherit;
	text-transform: none;
}

/* ---- 4. IMAGES + VIDEO: the biggest offender.  The connected-site sheet forces width / max-width /
        object-fit / border-radius / centring margins onto `.layout-container p img`, which re-frames album
        thumbnails and breaks resize.  Neutralise the framing here; each component then states its own. ---- */
.zt-album-scope.zt-album-scope.zt-album-scope img,
.zt-album-scope.zt-album-scope.zt-album-scope video {
	margin: 0;
	padding: 0;
	border: 0;
	float: none;
	box-shadow: none;
	filter: none;
}

/* ⛔ NEVER PUT max-width / max-height IN THE BLANKET IMAGE RULE.  The first version set them to `none` to
   defeat the site sheet's `max-width: 599px`, and that ALSO removed the constraint the lightbox depends on
   (.ztlb-frame / .ztlb-img are max-width:100%; max-height:100%) — every photo then rendered at its natural
   pixel size and overflowed the stage, which reads as "all my pictures are zoomed in".  Sizing is stated
   PER SURFACE below instead, where the right answer is actually known. */

/* THE GRID TILE THUMBNAIL — restated at armour strength so no sheet can re-frame it. */
/* THE GRID TILE THUMBNAIL — width is governed by the tile, so max-width must not clamp it (the site sheet
   caps canvas images at 640px, which would shrink a wide tile). */
.zt-album-scope.zt-album-scope.zt-album-scope .ca-thumb {
	display: block;
	width: 100%;
	height: auto;
	max-width: none;
	aspect-ratio: 1 / 1;
	object-fit: cover;
	border-radius: 0;
}

.zt-album-scope.zt-album-scope.zt-album-scope .ca-cinematic .ca-thumb { aspect-ratio: 16 / 9; }

/* THE CINEMATIC CARD'S TITLE BAR.  IT IS A <span>, SO RULE 3'S `span:not(.zt-fa)` RESET ABOVE STRIPS ITS
   padding TO 0 AND ITS background TO none — the component's own scoped rule is only one class deep and
   loses.  That is why the bar rendered as tight text on a flat black card.  Restated here at armour
   strength: a real grey band with room to breathe.  Keep in step with ZtConnectedAlbumView.razor.css. */
/* ⚠️⚠️ THE `span.` PREFIX IS NOT COSMETIC — IT IS THE WHOLE FIX.  Rule 3's reset is
   `.zt-album-scope×3 span:not(.zt-fa)`, whose specificity is (0,4,1): three scope classes, PLUS one for
   `:not(.zt-fa)` (a :not() takes the specificity of its argument), PLUS one element for `span`.  A rule
   written as `.zt-album-scope×3 .ca-cine-title` is only (0,4,0) and therefore LOSES to it — the padding and
   background go straight back to 0/none and the bar renders exactly as it did before.  Matching `span` here
   makes this (0,4,1) too, and it sits AFTER the reset in this file, so it wins on source order.
   COUNT THE ELEMENT SELECTOR when you armour anything the generic resets already target. */
.zt-album-scope.zt-album-scope.zt-album-scope span.ca-cine-title {
	display: block;
	padding: 0.8em 0.9em;
	background: #2e2e2e;
	color: #f2f2f2;
	line-height: 1.3;
}

.zt-album-scope.zt-album-scope.zt-album-scope span.ca-cine-title.ca-cine-untitled {
	color: #9a9a9a;
	font-style: italic;
}

/* ---- THE VIEWER MUST CLEAR THE FIXED NAV BAR ------------------------------------------------------
   `.ztlb-overlay` is `position: fixed; inset: 0`, i.e. the FULL viewport — including the 60px strip the
   fixed `.zt-navbar` occupies.  Two things went wrong because of that, and they are the SAME bug:
     * the media stage was a whole nav-bar taller than the space actually available, so a video letterboxed
       to that height and read as "too tall / doesn't fit";
     * `.ztvp-controls` is pinned to `bottom: 0` of that stage, which put the ENTIRE control bar - play,
       time, seek bar, scrub preview - underneath the nav, which is why there was "no timeline".
   Clearing the nav fixes both at once, and matches the house rule every other page follows
   (bottom spacing = the LIVE `--zt-navbar-h`, never a hard-coded number).
   Specificity: the overlay carries `zt-album-scope` ITSELF, so stacking the class on the same element
   gives (0,4,0) and beats the component's own scoped rule (0,2,0). ---- */
.ztlb-overlay.zt-album-scope.zt-album-scope.zt-album-scope {
	bottom: var(--zt-navbar-h, 60px);
}

/* NAV DOCKED TO THE TOP (the site's NavPosition="top") - clear the top edge instead, not the bottom. */
body:has(.zt-tenant-nav-top) .ztlb-overlay.zt-album-scope.zt-album-scope.zt-album-scope {
	top: var(--zt-navbar-h, 60px);
	bottom: 0;
}

/* NATIVE FULLSCREEN — the player element is promoted out of the page, so there is no nav bar to dodge and
   the clearance must not shrink it.  :fullscreen matches the wrap itself when it is the fullscreen element. */
.zt-album-scope.zt-album-scope.zt-album-scope .ztvp-wrap:fullscreen {
	width: 100%;
	height: 100%;
}

/* THE LIGHTBOX FRAME — must letterbox the WHOLE photo inside the stage.  These are the component's own
   values restated at armour strength: the frame fits the box, the photo letterboxes inside it. */
.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-frame,
.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-frame-in,
.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-frame-out {
	max-width: 100%;
	max-height: 100%;
	min-width: 0;
	min-height: 0;
}

.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-img {
	max-width: 100%;
	max-height: 100%;
	object-fit: contain;
	border-radius: 4px;
}

/* THE LIGHTBOX VIDEO — the same job .ztlb-img does for a photo, for the player.  The wrap carries the
   .ztlb-frame classes (so the rule above already fits it to the stage); these two make the wrap FILL that
   box and the <video> letterbox inside it.  Without them the <video> has no size at all and falls back to
   its intrinsic pixel dimensions, which overflows the stage — including in fullscreen. */
/* ⚠️ position:relative IS LOAD-BEARING, NOT DECORATION — .ztvp-controls is position:absolute against this
   wrap.  Drop it and the control bar anchors to the lightbox overlay instead and lands at the bottom of
   the screen rather than on the video. */
/* ⚠️ align/justify-self: stretch IS THE LOAD-BEARING BIT.  .ztlb-stage is a grid with place-items:center,
   which sizes its items BY THEIR CONTENT.  A photo has intrinsic dimensions so that works; this wrap is a
   bare <div> that has none, so it would be sized by the <video> inside it while the <video> is sized by the
   wrap - circular, and the browser falls back to the video's intrinsic pixels, which is the overflow.
   Stretching gives the wrap a DEFINITE box (the whole stage cell); object-fit:contain then letterboxes the
   video inside it, and the absolutely-positioned control bar has a real box to sit at the bottom of. */
.zt-album-scope.zt-album-scope.zt-album-scope .ztvp-wrap {
	position: relative;
	display: block;
	align-self: stretch;
	justify-self: stretch;
	width: 100%;
	height: 100%;
	background: #000;
	overflow: hidden;
}

.zt-album-scope.zt-album-scope.zt-album-scope .ztvp-video {
	display: block;
	width: 100%;
	height: 100%;
	max-width: 100%;
	max-height: 100%;
	object-fit: contain;
	background: #000;
	cursor: pointer;
}

/* THE DISPLAY-CROP WRAPPER — its image is deliberately oversized and offset; nothing may clamp it. */
.zt-album-scope.zt-album-scope.zt-album-scope .zt-crop > img {
	object-fit: fill;
	max-width: none;
	width: calc(100% / var(--zt-crop-w, 1));
	height: auto;
}

/* THE ALBUMS-INDEX COVER — a background-image tile, so a template's img rules must not reach it. */
.zt-album-scope.zt-album-scope.zt-album-scope .apc-cover {
	background-size: cover;
	background-position: center;
	background-repeat: no-repeat;
}

/* ---- 5. CONTROLS: buttons and inputs inside albums are ours, not the template's ---- */
.zt-album-scope.zt-album-scope.zt-album-scope button,
.zt-album-scope.zt-album-scope.zt-album-scope input,
.zt-album-scope.zt-album-scope.zt-album-scope textarea,
.zt-album-scope.zt-album-scope.zt-album-scope label {
	font-family: inherit;
	letter-spacing: normal;
	text-transform: none;
	box-shadow: none;
}

/* IF IT DOES SOMETHING WHEN YOU CLICK IT, IT SHOWS THE HAND — no exceptions, whatever the site sheet says.
   Albums are full of CLICKABLE NON-BUTTONS (reaction/vote <span>s, the Reply <a> with no href, gallery
   tiles), and an <a> without an href gets the TEXT caret by default, so "Reply" read as unclickable. */
.zt-album-scope.zt-album-scope.zt-album-scope button,
.zt-album-scope.zt-album-scope.zt-album-scope a,
.zt-album-scope.zt-album-scope.zt-album-scope label,
.zt-album-scope.zt-album-scope.zt-album-scope .zt-clickable,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-reply-link,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-react-btn,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-vote-btn,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-mod-btn,
.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-react-btn,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-tile,
.zt-album-scope.zt-album-scope.zt-album-scope .ca-emoji,
.zt-album-scope.zt-album-scope.zt-album-scope .ep-emoji,
.zt-album-scope.zt-album-scope.zt-album-scope .ep-tab {
	cursor: pointer;
}

/* ...AND IF IT IS OFF, IT SAYS SO. */
.zt-album-scope.zt-album-scope.zt-album-scope button:disabled {
	cursor: not-allowed;
	opacity: 0.6;
}

/* THE HOUSE "THIS IS CLICKABLE" UTILITY — the markup already used .zt-clickable, but the class was never
   actually defined anywhere, so it styled nothing at all.  Defined here, globally, for every surface. */
.zt-clickable {
	cursor: pointer;
}

/* ---- 6. THE GRID ITSELF: a template's own grid/flex rules must not reflow our gallery ---- */
.zt-album-scope.zt-album-scope.zt-album-scope .ca-gallery {
	display: grid;
	float: none;
	max-width: none;
	width: auto;
}

/* ---- 7. CARVE-OUT: A HOUSE MODAL OPENED FROM AN ALBUM IS NOT AN ALBUM SURFACE ----------------------
   The photo-details ZtModal is a DOM DESCENDANT of this scope — the lightbox lives inside
   ZtConnectedAlbumView's `.ca-album`, and showModal()'s top layer changes where a <dialog> PAINTS, not
   what it INHERITS.  Almost all of the armour is harmless there because ZtModal states its own colours
   (`.zt-modal-header` per theme, `.zt-modal-body { color: #6e6e6e }`), and `.ztbtn` carries no box-shadow
   for rule 5 to strip.  The ONE casualty is rule 3's anchor reset, which kills the underline on a link
   inside modal content — so that is restored here at armour strength.

   ⚠️ IF YOU ADD A DECLARATION TO THE ARMOUR, ASK WHETHER IT REACHES A MODAL. Anything INHERITED (colour,
   font, line-height) crosses into one; anything the modal restates for itself does not. */
.zt-album-scope.zt-album-scope.zt-album-scope .ztlb-info-link {
	text-decoration: underline;
}

/* =====================================================================
   ZT VIDEO PLAYER - custom controls + scrub preview (ztVideoPlayer JS).
   PORTED VERBATIM from the legacy wwwroot/css/video-player.css, which was
   never brought across with video-player.js - so the player had NO styles
   at all in Maui and the video overflowed its stage.
   Global (not scoped) because the control DOM is created in JavaScript.
   SIZING (.ztvp-wrap / .ztvp-video) lives in the ALBUM ARMOUR above, at
   armour strength, so a tenant sheet cannot re-frame a video.
   ===================================================================== */
/* ---- CONTROL BAR ---- */
.ztvp-controls {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    align-items: center;
    gap: 0.6em;
    padding: 0.9em 0.9em 0.7em;
    background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0));
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s ease;
    z-index: 5;
}

/* SHOW WHEN ACTIVE (recent mouse move) OR WHENEVER PAUSED. */
.ztvp-wrap.ztvp-active .ztvp-controls,
.ztvp-wrap:not(.ztvp-playing) .ztvp-controls {
    opacity: 1;
    pointer-events: auto;
}

.ztvp-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    background: transparent;
    color: #fff;
    cursor: pointer;
    opacity: 0.92;
    transition: opacity 0.12s ease, transform 0.12s ease;
}

.ztvp-btn:hover {
    opacity: 1;
    transform: scale(1.08);
}

/* THE GLYPH INSIDE A PLAYER BUTTON.  The legacy selector was `.ztvp-btn i` because that app emitted <i>;
   the house icon system renders a .zt-fa SPAN instead, so match that.  Stated at ARMOUR STRENGTH as well
   (below) because rule 3's span reset would otherwise reach it. */
.ztvp-btn .zt-fa {
    font-size: 1.2rem;
    line-height: 1;
}

/* THE PLAYER'S OWN ICONS INSIDE AN ALBUM — .zt-fa is already excluded from the armour's span reset, but the
   SIZE still has to beat any tenant sheet, so restate it at armour strength. */
.zt-album-scope.zt-album-scope.zt-album-scope .ztvp-btn span.zt-fa {
    font-size: 1.2rem;
    line-height: 1;
    color: #fff;
}

.ztvp-time {
    color: #fff;
    font-size: 0.82rem;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* ---- SEEK BAR ---- */
.ztvp-seek {
    position: relative;
    flex: 1 1 auto;
    height: 5px;
    margin: 0 0.3em;
    background: rgba(255, 255, 255, 0.28);
    border-radius: 3px;
    cursor: pointer;
    transition: height 0.1s ease;
}

.ztvp-seek:hover {
    height: 7px;
}

.ztvp-buffered,
.ztvp-played {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    border-radius: 3px;
    pointer-events: none;
}

.ztvp-buffered {
    background: rgba(255, 255, 255, 0.4);
    width: 0;
}

.ztvp-played {
    background: #4a76c4;
    width: 0;
}

.ztvp-head {
    position: absolute;
    top: 50%;
    left: 0;
    width: 13px;
    height: 13px;
    margin-left: -6.5px;
    border-radius: 50%;
    background: #fff;
    transform: translateY(-50%);
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
    opacity: 0;
    transition: opacity 0.12s ease;
    pointer-events: none;
}

.ztvp-seek:hover .ztvp-head {
    opacity: 1;
}

/* ---- VOLUME ---- */
.ztvp-vol {
    width: 72px;
    height: 4px;
    accent-color: #4a76c4;
    cursor: pointer;
}

/* ---- SCRUB PREVIEW (storyboard) ---- */
.ztvp-preview {
    position: absolute;
    bottom: 56px;
    transform: translateX(-50%);
    display: none;
    flex-direction: column;
    align-items: center;
    padding: 5px;
    background: rgba(0, 0, 0, 0.85);
    border-radius: 6px;
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.5);
    pointer-events: none;
    z-index: 6;
}

.ztvp-preview.ztvp-preview-on {
    display: flex;
}

.ztvp-preview-img {
    display: block;
    border-radius: 3px;
    background-repeat: no-repeat;
    background-color: #111;
}

.ztvp-preview-time {
    margin-top: 4px;
    color: #fff;
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
}

/* A SCHEDULED PRICE STEP ROW in the event editor — date | price | delete on one line, collapsing to a stack on
   Compact widths (the ladder's <=599px band) so nothing scrolls sideways on a phone. */
.zt-event-tier-row { display: flex; align-items: flex-start; gap: 0.6rem; }
.zt-event-tier-row > *:first-child { flex: 1.4 1 0; min-width: 0; }
.zt-event-tier-row > *:nth-child(2) { flex: 1 1 0; min-width: 0; }
.zt-event-tier-row > *:last-child { flex: 0 0 auto; margin-top: 1.6rem; }
@media (max-width: 599px) {
	.zt-event-tier-row { flex-wrap: wrap; }
	.zt-event-tier-row > *:first-child, .zt-event-tier-row > *:nth-child(2) { flex: 1 1 100%; }
	.zt-event-tier-row > *:last-child { margin-top: 0; }
}

/* ── THE CONTROL LIBRARY (/admin/controls) ──────────────────────────────────────────────────────────────────
   The catalogue page's ONLY five classes, and not one of them draws a control. Everything else on that page is
   a real library control, which is the whole point of it — a catalogue that had to hand-roll its own look would
   be arguing against itself.

   THE SHAPE OF ONE ENTRY (Vince, 2026-08-21) is: the wave-section band carrying the control's NAME, then the
   live control, then the notes about it as small alerts. The control is the subject, so it sits directly under
   its own name; the prose is reference material and goes below it.

   .zt-control-demo is the WELL that live control sits in: a dashed hairline that says "this is the control, not
   the page". Dashed and not solid on purpose — a solid border reads as a card, and half the examples inside it
   ARE cards. The row variant is for examples that are a set of small things (buttons, badges, icons) rather
   than one stacked thing.

   NO WIDTH RULE HERE, and none is wanted: the well is a plain block that takes the width its section gives it,
   the row wraps, and every control inside brings its own small-screen behaviour. So the page needs nothing
   from the breakpoint ladder — it inherits Compact/Medium/Expanded from its contents. */
/* EACH ENTRY IS A WAVE SECTION (it carries zt-wave-block), so all this adds is the rhythm between them — the
   0.85rem a real wave body supplies with its flex gap. Matching that number is the point: the catalogue's
   sections should sit apart by exactly as much as sections in a wave editor do. */
.zt-control-entry { margin-bottom: 0.85rem; }

/* THE ENTRY'S BODY IS A COLUMN WITH A GAP — the demo well first, then the note alerts. It is the wave body's
   own rhythm, borrowed for the same reason: children that each carry their own margin drift apart at different
   rates, and the option-picker demo ended up with its cards jammed straight against the alert underneath them
   (Vince, 2026-08-21: "there's no vertical space between where it shows the option picker like previews and
   then there's like a little alert underneath"). One gap owns the spacing; the alerts give theirs up below. */
.zt-control-entry > .zt-page-content-body {
	display: flex;
	flex-direction: column;
	gap: 0.6rem;
}
/* ⚠️ SAME TRAP AS .zt-wave-body: a flex item defaults to flex-shrink:1, so anything with an intrinsic height
   (a chart canvas, the signature pad, the loader stage) gets squeezed instead of keeping its size. */
.zt-control-entry > .zt-page-content-body > * { flex-shrink: 0; }

/* A DESCRIPTION-ONLY ENTRY collapses to its band. ZtPageContent always emits the body element, so without this
   an entry with no live example (a charge row, a kiosk stage — the ones that need a real DTO or the whole
   viewport) would show an empty padded box under its heading. */
.zt-control-entry-bare > .zt-page-content-body { display: none; }

/* THE NOTE ALERTS AND ANY ALERT INSIDE A DEMO GIVE UP THEIR OWN MARGIN — the flex gap above and inside the
   well is the single source of vertical rhythm here. Leaving .zt-alert's 0.75em on would add it to the gap. */
.zt-control-entry .zt-alert { margin-bottom: 0; }

.zt-control-demo {
	position: relative;   /* ⚠️ LOAD-BEARING: the well must be the positioned ancestor for any demo whose control
							 is absolutely positioned. .zt-warp (the starfield) is inset:0 against its nearest
							 positioned ancestor, so without this the loader example escapes its box and paints
							 stars across whatever card happens to be positioned further up. */
	/* THE WELL IS ITSELF A COLUMN. A demo is often several controls (three flagged rows, five alerts, a picker
	   AND the alert that reports what it raised) and they need air between them, not just around them. */
	display: flex;
	flex-direction: column;
	gap: 0.65rem;
	padding: 0.85rem;
	border: 1px dashed var(--zeal-silver, #9a9a9a);
	border-radius: 6px;
	/* WHITE, NOT A BLACK WASH. The well now sits on the wave section's --zeal-blue-wash body, and a translucent
	   black over that just muddied the blue. White separates "this is the live control" from "this is the
	   description" without introducing a third colour. */
	background: #ffffff;
	min-width: 0;   /* so a long label inside can never push the well wider than its section */
	/* ⛔ NO overflow:hidden. Half the examples in these wells are dropdowns, date pickers and colour pickers
	   whose overlays legitimately extend past the box; clipping them would break the very controls on show. */
}

/* A DARK STAGE for the one example that needs a night sky behind it — ZtLoader. The starfield's stars are pale
   dots on transparency: on the well's near-white background there is literally nothing to see. Everywhere else
   in the app that darkness comes from the ZtBusy overlay, which the catalogue is deliberately not using here. */
.zt-control-stage {
	position: relative;
	display: flex;
	align-items: center;
	justify-content: center;
	min-height: 12rem;
	border-radius: 6px;
	background: #050508;
}

/* A SET OF SMALL THINGS, not one big thing — wraps, so twelve verb buttons stack down instead of scrolling
   sideways on a phone. align-items:center keeps mixed heights (a badge beside a button) on one baseline.
   ⚠️ flex-direction MUST be restated: the base .zt-control-demo above is a COLUMN now, and this modifier is
   the same one-class specificity, so it only wins on source order — leave it after the base rule. */
.zt-control-demo-row {
	display: flex;
	flex-direction: row;
	flex-wrap: wrap;
	align-items: center;
	gap: 0.5rem;
}

/* ── THE NOTE ALERTS UNDER A CONTROL ────────────────────────────────────────────────────────────────────────
   Vince asked for "much smaller text like you have in the alert below where it says on selected fired", and
   that alert is a plain ZtAlert — so the SIZE is already right at .zt-alert's 0.92rem and is deliberately not
   changed here. What this class does is tighten the label: an alert title is sized for a banner announcing one
   thing, and four stacked labels reading What it is / Use it for / Watch out are captions, not announcements. */
.zt-control-note > .zt-alert-body > .zt-alert-title {
	font-size: 0.82rem;
	text-transform: none;   /* ⛔ never uppercase — the button-label rule's reasoning applies to labels too */
	margin-bottom: 1px;
}
/* The alerts sit under the control as a reference stack, so they align to the TOP of their icon rather than
   centring on it — .zt-alert's base flips to align-items:center, which reads oddly on a two-line note. */
/* The notes now live in the entry's info WAVE, and .zt-wave-body is a flex column with its own gap — an alert's
   default 0.75em bottom margin would add to that gap rather than replace it. (They are outside .zt-control-entry
   now, so the margin reset up in the catalogue block no longer reaches them.) */
.zt-control-note { align-items: flex-start; margin-bottom: 0; }
.zt-control-note > .zt-alert-icon { line-height: 1.3; }

/* ═══ THE FLYER'S REGISTER BUTTON ═══════════════════════════════════════════════════════════════════════════
   Vince, 2026-08-22: "our default click here to register now is just like not sexy enough. There is no icon
   there. It's kinda too big and cartoony and just, like, old school."

   ⛔ THE OLD ONE WAS 14px/40px OF INLINE STYLE BAKED INTO THE SECTION HTML, which is exactly why it had gone
   stale and could not be improved: every event page carried its own frozen copy of the design. This is now a
   CLASS, so the button follows the product forward and one rule restyles every event page at once.
   ⚠️ Pages created BEFORE this change still carry the inline-styled anchor — their section HTML has to be
   rewritten to pick this up.

   WHAT MAKES IT READ AS MODERN RATHER THAN AS A 2011 CALL-TO-ACTION:
     · a 10px radius instead of a 9999px lozenge — a pill that wide reads as a cartoon speech bubble
     · smaller and tighter: 0.95rem at 0.65/1.15rem padding, not 1.1em at 14px/40px
     · a diagonal gradient with a real elevation shadow (tight contact shadow + wide soft one), not one flat
       0 2px 6px drop shadow
     · a duotone ticket in its own translucent chip, and an arrow that steps right on hover
     · a sheen that sweeps across on hover
   The face is deliberately NOT set: the button inherits the academy's own site typography, so it looks like
   part of their site rather than like something the platform bolted on. */

.zt-flyer-cta {
	display: inline-flex;
	align-items: center;
	gap: 0.65rem;
	padding: 0.65rem 1.15rem;
	border-radius: 10px;
	background-image: linear-gradient(135deg, #1fa355 0%, #12833f 55%, #0d6d34 100%);
	color: #ffffff;
	font-size: 0.95rem;
	font-weight: 600;
	letter-spacing: 0.005em;
	line-height: 1.2;
	text-decoration: none;
	border: 1px solid rgba(0, 0, 0, 0.08);
	/* TWO SHADOWS, NOT ONE: a tight contact shadow that sits the button ON the page, and a wide soft one that
	   lifts it OFF it. A single mid-blur drop shadow is what makes a button look printed rather than raised. */
	box-shadow: 0 1px 2px rgba(10, 60, 30, 0.30), 0 8px 20px -8px rgba(13, 109, 52, 0.60);
	position: relative;
	overflow: hidden;
	isolation: isolate;
	transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
}

/* THE ICON CHIP — a translucent well rather than a bare glyph, so the icon reads as part of the button's
   material instead of as a sticker on top of it. */
.zt-flyer-cta .zt-flyer-cta-ic {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	width: 1.6rem;
	height: 1.6rem;
	border-radius: 7px;
	background: rgba(255, 255, 255, 0.18);
	font-size: 0.9rem;
	flex: 0 0 auto;
}

/* THE ARROW — the whole point of it is the 3px it moves on hover. */
.zt-flyer-cta .zt-flyer-cta-go {
	font-size: 0.8rem;
	opacity: 0.9;
	transition: transform 0.18s ease;
}

/* THE SHEEN, parked off the left edge until a hover sweeps it across. */
.zt-flyer-cta::after {
	content: "";
	position: absolute;
	inset: 0 auto 0 -60%;
	width: 45%;
	z-index: -1;
	background: linear-gradient(100deg, transparent 0%, rgba(255, 255, 255, 0.28) 50%, transparent 100%);
	transform: skewX(-18deg);
	transition: left 0.55s ease;
}

/* ⚠️ HOVER ONLY WHERE HOVER IS REAL. On a touch screen :hover sticks after a tap, which would leave the sheen
   frozen mid-sweep across the button somebody just pressed. */
@media (hover: hover) {
	.zt-flyer-cta:hover {
		transform: translateY(-1px);
		filter: saturate(1.06);
		box-shadow: 0 2px 4px rgba(10, 60, 30, 0.32), 0 14px 28px -10px rgba(13, 109, 52, 0.70);
	}

	.zt-flyer-cta:hover .zt-flyer-cta-go { transform: translateX(3px); }
	.zt-flyer-cta:hover::after { left: 115%; }
}

.zt-flyer-cta:active {
	transform: translateY(0);
	box-shadow: 0 1px 2px rgba(10, 60, 30, 0.34);
}

/* KEYBOARD USERS GET A REAL RING. The button lives inside owner-authored content on a public marketing page —
   it is frequently the only interactive thing in the section. */
.zt-flyer-cta:focus-visible {
	outline: 2px solid #0d6d34;
	outline-offset: 3px;
}

/* NO MOTION FOR ANYONE WHO ASKED FOR NONE — the button still changes on hover, it just stops moving. */
@media (prefers-reduced-motion: reduce) {
	.zt-flyer-cta,
	.zt-flyer-cta .zt-flyer-cta-go,
	.zt-flyer-cta::after {
		transition: none;
	}

	.zt-flyer-cta:hover { transform: none; }
	.zt-flyer-cta:hover .zt-flyer-cta-go { transform: none; }
	.zt-flyer-cta:hover::after { left: -60%; }
}
