:root {
  color-scheme: dark;

  /* ---------- Design tokens ---------- */
  /* Cold Steel -- desaturated teal (~45% sat) on graphite-blue neutrals.
     Replaced an earlier muted-violet palette (migrated page by page,
     completed); that palette had itself replaced a more saturated
     purple before it. Deliberately kept desaturated rather than bright
     cyan -- a teal glow reads noticeably more "neon hacker" than this
     hue does at the same alpha, confirmed by rendering both side by
     side during the migration, not assumed. Status colors (below) are
     kept in a completely separate hue family from the accent so an
     alert never blends with branding.
     NOTE: the --violet* variable NAMES are a holdover from the palette
     before this one -- kept as-is rather than renamed everywhere
     they're used (a pure naming change, not worth the diff); only the
     VALUES change when the palette does. */
  --bg-void: #0a0d10;
  --bg-surface: #12161b;
  --bg-surface-raised: #191e24;
  --border-subtle: #232a31;
  --border-strong: #333c45;

  --text-primary: #e8edf0;
  --text-secondary: #a7b0b8;
  --text-tertiary: #798088;

  --red: #dd5c55;
  --red-glow: rgba(221, 92, 85, 0.28);

  --violet: #3fa9a0;
  --violet-soft: #55bdb4;
  --violet-glow: rgba(63, 169, 160, 0.22);
  --violet-button: #286b65;

  /* Low-alpha accent tints for hover backdrops (nav links, login button,
     Contact's hero photo overlay). Dedicated tokens rather than reusing
     --violet-glow (0.22 alpha, tuned for box-shadow bloom) because these
     need a much lighter 0.08-0.18 wash to read as a backdrop/overlay,
     not a glow. */
  --nav-hover-tint: rgba(63, 169, 160, 0.1);
  --btn-login-tint: rgba(63, 169, 160, 0.08);
  --hero-visual-tint: rgba(63, 169, 160, 0.18);

  /* Three-tier detection confidence — MUST stay in sync with
     detection.py's TIER_COLORS_HEX (single source of truth is Python;
     CSS can't import it, so these are a deliberate mirror). */
  --tier-authorized: #4cae82;
  --tier-borderline: #d6a93c;
  --tier-unknown: #dd5c55;
  /* Low-alpha tint/border pair for the "verified" proof badge -- same
     drift bug as --nav-hover-tint: was hardcoded to the pre-Cold-Steel
     green (rgb(16,185,129)) and never updated when --tier-authorized's
     value changed, caught while rebuilding this section. */
  --tier-authorized-tint: rgba(76, 174, 130, 0.1);
  --tier-authorized-border: rgba(76, 174, 130, 0.3);
  /* Tint/border pairs for the other two status hues (design-token
     consolidation pass, 2026-08-22): only --tier-authorized had one of
     these until now, so every OTHER status treatment in this codebase
     hand-rolled its own one-off rgba() border/background instead of
     reusing a token -- e.g. admin.css's .admin-request-status-pending
     already uses border-color: rgba(214, 169, 60, 0.35), the exact
     value --tier-borderline-border below now formalizes (same number,
     not a new one). Added so the new shared .status-badge component
     below (and any future one) has a real token to point at instead of
     adding a third hand-rolled copy of the same value. */
  --tier-borderline-tint: rgba(214, 169, 60, 0.1);
  --tier-borderline-border: rgba(214, 169, 60, 0.35);
  /* --tier-unknown and --red are the same hex value by design (see
     above) -- one tint/border pair serves both names. */
  --red-tint: rgba(221, 92, 85, 0.12);
  --red-border: rgba(221, 92, 85, 0.4);
  /* Matches the border alpha .admin-role-admin/.admin-request-status-approved
     already hand-roll (rgba(63, 169, 160, 0.4)) for this exact "violet
     pill border" treatment -- consolidated into a named token rather
     than a third copy. (.alert-banner.success's own tint/border pair
     is a slightly different alpha, tuned for a full-width banner, not
     a small pill -- left as-is, not merged into this one.) */
  --violet-tint: rgba(63, 169, 160, 0.12);
  --violet-border: rgba(63, 169, 160, 0.4);

  /* Liquid-glass surface: translucent + blurred rather than flat, for
     component panels (cards, HUD panels, forms). Needs *something*
     behind it to blur — see body's background-image below — or the
     blur has no visible effect. */
  --glass-bg: rgba(25, 30, 36, 0.6);
  --glass-border: rgba(255, 255, 255, 0.07);
  --glass-border-hover: rgba(63, 169, 160, 0.3);
  --glass-blur: blur(16px) saturate(150%);

  /* Satoshi migration (2026-08-19): Satoshi is the site-wide body
     typeface. IBM Plex Mono is reserved for literal machine/data values
     only (timestamps, confidence scores, feed/camera IDs, code
     identifiers) -- never general UI text.
     Typography-pairing fix (UI polish pass, 2026-08-21): --font-display
     and --font-body had been collapsed to the exact same family (a
     design-audit finding -- a page with one typeface for everything
     reads as unstyled, not "clean"). Reintroduced Clash Display as the
     dedicated display face for h1/h2-scale headlines -- notably NOT a
     new typeface for this project: a stale comment further down this
     file (search "Clash Display is a large-headline face") already
     described exactly this face's large-headline character and small-
     size muddiness, left over from before the Satoshi migration
     flattened everything to one family. Same distribution mechanism as
     Satoshi (Fontshare's API, already CSP-allowlisted below and in
     app.py) and the same Indian Type Foundry free-license family, so no
     new licensing question is introduced. Applies via these root tokens
     to every page (public site, dashboard, admin, content editor) -- see
     templates/_partials/fonts.html for the actual <link> load. */
  --font-display: "Clash Display", "Satoshi", -apple-system, "Segoe UI", system-ui, sans-serif;
  --font-body: "Satoshi", -apple-system, "Segoe UI", system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;

  /* Type scale (design-token consolidation pass, 2026-08-22). This file
     has accumulated dozens of freehand font-size values (0.66rem
     through 1.9rem, plus several bespoke clamp() headline sizes) with
     no shared scale to anchor new work to -- the same class of gap the
     spacing/radius scales just below this already closed for their own
     properties. This is NOT a retrofit of every existing value (out of
     this pass's scope: that would mean editing admin.css/dashboard.css,
     owned by other agents) -- these eight steps round the most common
     existing clusters into real, named sizes for genuinely NEW or
     refactored component work to build against instead of adding a
     ninth one-off number. This file's own new .status-badge/
     .empty-state/.loading-inline components (below) already use them. */
  --text-xs: 0.7rem;    /* mono labels, badges, table meta -- matches the existing 0.66-0.72rem cluster */
  --text-sm: 0.8rem;    /* secondary body copy, table cells -- matches the existing 0.76-0.82rem cluster */
  --text-base: 0.9rem;  /* default paragraph/body text -- matches the existing 0.87-0.92rem cluster */
  --text-md: 1rem;      /* emphasized body text, card titles */
  --text-lg: 1.15rem;   /* section sub-headers, lead paragraphs */
  --text-xl: 1.3rem;    /* matches h2.section-title exactly */
  --text-display-sm: clamp(1.5rem, 5vw, 1.9rem);   /* matches .page-title exactly */
  --text-display-lg: clamp(2.75rem, 5vw, 4.25rem); /* matches .hp-hero-headline exactly */

  /* ---------- Foundations added for the dashboard component pass
     (2026-08-18) -- formalizing what was already implicit in the
     existing CSS, not inventing a new visual language. Every token here
     is used by the dashboard component work; nothing speculative. ---------- */

  /* Spacing scale. The rest of this file still uses freehand px values
     (a pre-existing, out-of-scope-for-this-pass inconsistency) -- new
     dashboard component CSS uses this scale so it doesn't add MORE
     one-off values to that pile. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  /* Pixel-precision implementation (2026-09-04): smallest necessary
     extension to the scale above -- large-section rhythm had no token
     (the biggest existing step was 32px; .hp-section's own 96px
     margin-bottom was already a hardcoded literal, not a token). Added
     only where section-level pacing genuinely needs a bigger step than
     --space-6, not a replacement for the scale above. */
  --space-7: 48px;
  --space-8: 96px;
  --space-9: 144px;

  /* Site-wide restrained radius scale (visual restyle, 2026-08-19).
     Deliberately smaller than body.shape-rounded's own scale further
     down this file (--radius-sm/md/lg/pill: 12/16/20/999px) -- that
     block is home.html-exclusive (Home only, "not yet applied anywhere
     else" per its own comment) and stays completely untouched; CSS
     custom-property scoping means these root defaults are simply
     overridden wherever .shape-rounded is present (home.html only), so
     defining these here cannot change the homepage's rendering at all.
     Everywhere else in the product (public pages other than Home,
     dashboard, admin, content editor) should use these instead of a
     new one-off border-radius value or the homepage's pill/oversized
     scale -- "engineered," not "soft UI": no 999px pills, no 20-30px
     card corners. A fully circular shape (an avatar, a status dot) is a
     different concept (border-radius: 50%), not a radius-scale value. */
  --radius-xs: 3px;
  --radius-sm: 5px;
  --radius-md: 8px;
  --radius-lg: 12px;
  /* Global, not just home's shape-rounded scale: .btn-login is shared
     nav chrome that sits in the exact same position on every page,
     home included, so it needs one radius value everywhere rather than
     following the sharp-corner/pill split below -- a user navigating
     between pages would otherwise see the same button change shape.
     Content buttons (.btn-primary/.btn-secondary) keep the intentional
     split; this token exists only for that one shared element. */
  --radius-pill: 999px;

  /* Focus ring: same solid-ring pattern input:focus already uses
     (border-color shift + a crisp box-shadow ring, not a blurred glow)
     -- extended here to buttons/links/nav items, which had no visible
     focus state at all before this pass.
     NOTE (UI polish pass, 2026-08-21, public-site scope only): the
     public marketing site's own focus-visible block further down this
     file no longer uses this token -- it was a box-shadow standing in
     for a real `outline` (doesn't participate in forced-colors/
     high-contrast mode the way a genuine outline does, and shared the
     `box-shadow` transition property with hover glows, so the ring
     visibly faded in over the hover transition's duration instead of
     appearing instantly). Rebuilt as a real `outline` there. This token
     is left exactly as-is because the dashboard's own focus-visible
     block (gated product surface, out of this pass's scope) still
     depends on it. */
  --focus-ring: 0 0 0 3px var(--violet-glow);

  /* Disabled state: one opacity value, reused everywhere something can
     be disabled, instead of each component inventing its own number
     (.content-image-save:disabled already used 0.4 -- close enough to
     round into one shared value going forward). */
  --disabled-opacity: 0.45;

  /* Elevation: ONE step, for the one dashboard surface that's genuinely
     floating above other content (the enroll-confirmation modal) rather
     than sitting flush in the page flow like every panel/card already
     does. Not a general-purpose shadow scale -- elevation is used
     sparingly by design, so one value is enough until a second real
     case shows up. */
  --elevation-modal: 0 12px 40px -8px rgba(0, 0, 0, 0.55);

  /* Named z-index scale for the public site's page-level fixed/sticky
     chrome (design-audit finding: arbitrary z-index values scattered
     through the file with no shared scale). Component-internal stacking
     (e.g. the demo player's own overlay/toggle/scan layers, each only
     ever compared against siblings inside that one component) is left
     as small literal numbers -- this scale is specifically for the
     page-level layers that can actually stack against EACH OTHER
     (nav, sticky mobile CTA, cookie banner, skip link). Scoped to the
     public site for this pass; the dashboard/admin's own z-index values
     are untouched (out of scope). */
  --z-sticky: 50;
  --z-fixed: 60;
  --z-overlay: 80;
  --z-skiplink: 1000;

  /* Breakpoint reference (documentation only -- CSS custom properties
     can't be used inside @media conditions). The existing stylesheet
     has accumulated 7 different ad-hoc breakpoints (460/560/640/
     720/780/800/900px); not retrofitting those here (out of scope for
     this pass), but new dashboard responsive CSS uses this 3-step scale
     instead of adding an 8th arbitrary number:
       mobile ≤ 480px | tablet ≤ 780px (matches the existing sidebar
       breakpoint) | desktop > 780px */
}

* { box-sizing: border-box; }

/* Standard visually-hidden-but-accessible utility (the well-known
   pattern, not a custom invention) -- used by the dashboard's data-table
   column headers that are visually blank by design (a photo/thumbnail/
   checkbox column needs no visible label, but still needs a real one
   for assistive tech; axe's empty-table-header rule flagged this
   during the Phase 1 accessibility scan). */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Skip link (accessibility audit finding: no way to bypass the sidebar
   nav on any authenticated page). Standard, well-known pattern: hidden
   off-screen until it receives keyboard focus, then rendered as a real,
   visible, high-contrast link above everything else. Moved here from
   admin.css (independent design review, Phase 7, 2026-08-20) -- it's a
   shared, site-wide accessibility utility, not an admin-panel-specific
   one; dashboard.html and portal.html both use this class but never
   loaded admin.css, so it was rendering completely unstyled on both.
   --violet fallback corrected from a stale pre-Cold-Steel purple
   (#7c5cff) to the current teal while moving this. */
.skip-link {
  position: absolute;
  top: -100px;
  left: 12px;
  z-index: var(--z-skiplink);
  padding: 10px 16px;
  background: var(--bg-surface, #12161b);
  color: var(--text-primary, #e8edf0);
  border: 1px solid var(--violet, #3fa9a0);
  border-radius: var(--radius-sm, 5px);
  font-family: var(--font-body, sans-serif);
  font-size: 0.85rem;
  text-decoration: none;
  transition: top 0.15s ease;
}
.skip-link:focus {
  top: 12px;
}

/* clip, not hidden: this page has sticky elements (.site-nav,
   .legal-toc) that a hidden overflow-x ancestor can silently break the
   stickiness of in some browsers -- clip prevents the same horizontal
   scrollbar without that side effect, and (unlike hidden) doesn't
   create a new scroll container. */
html, body { max-width: 100%; overflow-x: clip; }

body {
  margin: 0;
  font-family: var(--font-body);
  background: var(--bg-void);
  /* Very subtle fixed dot texture — mostly invisible on its own, but
     gives glass panels' backdrop-filter something to actually blur. */
  background-image: radial-gradient(circle, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
  background-size: 28px 28px;
  background-attachment: fixed;
  color: var(--text-secondary);
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* Cursor "smoke" effect removed (UI polish pass, design-audit finding):
   a full-viewport cursor-follower canvas ran on every single page,
   including /login and the legal pages -- pure decorative chrome with
   no relation to page content, exactly the kind of particle effect a
   restrained, technically-credible security product's site shouldn't
   have everywhere. static/js/cursor-trail.js is removed and no longer
   referenced from any template. */

a { color: inherit; }

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1 !important; transform: none !important; }
}

/* ---------- Motion: scroll reveal ---------- */
/* Applied via static/js/reveal.js (IntersectionObserver). Subtle by
   design — a security product's chrome should feel alert, not playful. */
.reveal {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.in-view { opacity: 1; transform: translateY(0); }

/* ---------- Motion: shared "live" pulse dot ---------- */
/* Same motif as the homepage hero badge, reused anywhere something is
   asserting "this is happening right now" (dashboard status, demo). */
.live-dot {
  display: inline-block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--violet);
  box-shadow: 0 0 0 0 var(--violet-glow);
  animation: pulse-glow 2.2s ease-out infinite;
}
.live-dot.alert { background: var(--red); box-shadow: 0 0 0 0 var(--red-glow); animation-name: pulse-glow-red; }
@keyframes pulse-glow-red {
  0%   { box-shadow: 0 0 0 0 var(--red-glow); }
  70%  { box-shadow: 0 0 0 8px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}

/* ---------- Motion: shared hover lift ---------- */
.hover-lift { transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; }
.hover-lift:hover { transform: translateY(-3px); }

/* ==================================================================
   Shared status / empty-state / loading-state component library
   (design-token consolidation pass, 2026-08-22) -- for the gated
   product surface (dashboard, admin, portal all load this file).
   Exists because this pass's audit found SIX different ad-hoc visual
   treatments already expressing the exact same "healthy / degraded /
   down / neutral" status semantic across that surface --
   .admin-user-role, .admin-role-*, .admin-request-status-* (a real
   bordered pill, this file, below), .admin-cam-health-*,
   .admin-recording-health-*, .admin-zone-status-* (bare colored text,
   admin.css), .portal-status-active/-revoked (bare colored bold text,
   portal.css), .status .dot (a colored dot + label, dashboard.css),
   and .stream-live-badge (an opaque chip with a dot, dashboard.css) --
   each independently reinventing the same three/four-state language
   with its own one-off rgba() value. This is NOT a rewrite of any of
   those (out of this pass's scope -- dashboard.css/admin.css/the
   templates themselves belong to three separate follow-up agents) --
   it's the ONE canonical component going forward: new status displays
   should be built against this, and the six above are candidates to
   migrate onto it next time an agent already has that markup open, not
   changed here. See NEO_DESIGN_SYSTEM.md for usage examples.

   Semantic mapping -- reuses this file's EXISTING status colors
   (--tier-authorized/-borderline/-unknown) rather than inventing a
   parallel success/warning/error palette. This project's own
   established convention (see admin.css's own .admin-cam-health-*
   comment) already treats these three as the general-purpose status
   triad, not just a face-match-confidence-specific one -- formalizing
   that convention here, not replacing it:
     --ok      -> --tier-authorized  (healthy / live / active / recording)
     --warn    -> --tier-borderline  (degraded / reconnecting / pending / stale)
     --error   -> --tier-unknown / --red (down / failed / revoked / rejected)
     --neutral -> --text-tertiary    (disabled / not configured / unknown)
     --info    -> --violet           (brand-neutral state: linked, admin role) ---- */

/* CSS-BREAKING BUG FIX (found + fixed during the admin/zoning visual
   pass, 2026-08-22, while verifying .status-badge actually rendered):
   three lines just above this one wrote a bare-class-name-with-wildcard
   immediately followed by a forward slash used as a list separator, with
   no space between them -- e.g. .admin-cam-health, then a trailing
   asterisk, then that separator slash, all touching. An asterisk
   directly followed by a slash, with nothing between them, is the same
   two-character sequence a real CSS parser reads as this whole banner
   comment's own close -- so it closed here, only ~7 lines into a
   ~34-line comment. (This note is deliberately phrased to never write
   that same two-character sequence adjacently itself, for the obvious
   reason.) Confirmed directly (not assumed): the browser's CSSOM had NO
   rule with selectorText exactly ".status-badge" at all -- only the
   later .status-badge--* modifier rules survived, because everything
   between that premature close and this file's real next comment got
   parsed as invalid, silently-dropped CSS, which swallowed the base
   .status-badge rule along with it. Fixed by inserting one space at each
   of the three offending spots (list text now reads identically to a
   person, just no longer forms that closing token) -- no wording,
   meaning, or design decision changed. This is a correctness fix to a
   file this pass does not otherwise own/edit (static/style.css's tokens
   are a separate agent's finalized work) -- made here because it
   silently broke the one shared component this
   pass was told to build against, sitewide (dashboard.html/portal.html
   load the same file), not just on admin.html. */
.status-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  line-height: 1.5;
  padding: 2px 9px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-secondary);
  white-space: nowrap;
}
.status-badge--ok { color: var(--tier-authorized); background: var(--tier-authorized-tint); border-color: var(--tier-authorized-border); }
.status-badge--warn { color: var(--tier-borderline); background: var(--tier-borderline-tint); border-color: var(--tier-borderline-border); }
/* color is a slightly lightened --red (#dd5c55 -> #e06862, same hue/
   identity), NOT the shared --red token itself -- a real axe-core finding
   (portal-a11y.spec.js, 2026-08-22) measured this exact text-on-
   translucent-tint combination at 4.38:1 against its typical composited
   background, just under WCAG AA's 4.5:1 for this badge's small
   (11px/0.6875rem) uppercase text. --red itself is used in several other
   contexts (admin.css's request/camera-test/recording-volume states,
   style.css's alert-banner.error, dash-sidebar-logout, portal.css's
   .portal-status-revoked) each against its own different background --
   changing the shared token to fix ONE composited case, without
   re-verifying contrast on every one of those, risks an unreviewed
   regression elsewhere. Scoped to just this selector instead; #e06862
   measures 4.84:1 against the same background, a real margin above the
   4.5:1 minimum, not a bare pass. */
.status-badge--error { color: #e06862; background: var(--red-tint); border-color: var(--red-border); }
.status-badge--neutral { color: var(--text-tertiary); background: transparent; border-color: var(--border-strong); }
.status-badge--info { color: var(--violet); background: var(--violet-tint); border-color: var(--violet-border); }

/* Optional leading dot, for a "this is live/ongoing right now" reading
   (the dashboard's camera-tile status line and stream badge both
   already do exactly this). A real child element, not a ::before, so a
   consumer needing the pulse animation reuses the keyframes this file
   already defines above (pulse-glow / pulse-glow-red) instead of a
   third copy of the same effect living here. */
.status-badge-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}
.status-badge-dot.pulse { animation: pulse-glow 2.2s ease-out infinite; }

/* ---------- Empty state ---------- */
/* One reusable shape for "there is genuinely nothing here yet" --
   consolidates .content-empty-state (admin_content.html, a dashed box)
   and .camera-tile-empty/.roster-empty (dashboard.html, two different
   treatments of the same idea) into one component with three real
   slots: an optional icon, a message, and an optional action. Dashed
   border (not solid, not a filled card) is deliberate -- it's this
   codebase's own existing convention for "this is a placeholder, not
   real content" (.content-empty-state already established it); a solid
   card here would make an empty list look like a loaded one. No
   illustration/graphic -- an optional small line-icon slot only, kept
   restrained per this project's own anti-slop standard. */
.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 40px 24px;
  text-align: center;
  border: 1px dashed var(--border-subtle);
  border-radius: var(--radius-md);
  color: var(--text-tertiary);
}
.empty-state-icon {
  width: 32px;
  height: 32px;
  color: var(--text-tertiary);
  opacity: 0.7;
}
.empty-state-icon svg { width: 100%; height: 100%; display: block; }
.empty-state-message {
  margin: 0;
  font-family: var(--font-body);
  font-size: var(--text-base);
  font-weight: 600;
  color: var(--text-secondary);
}
.empty-state-detail {
  margin: 0;
  max-width: 380px;
  font-size: var(--text-sm);
  line-height: 1.55;
  color: var(--text-tertiary);
}
.empty-state-action { margin-top: 6px; }
/* A tighter variant for a state rendering inside an already-small
   container (a single grid tile, a narrow panel column) rather than a
   full section -- same shape, less padding, no room assumed for a
   detail line or action button. */
.empty-state--compact { padding: 20px 16px; gap: 6px; }

/* ---------- Inline loading state ---------- */
/* A spinner + label row. .spinner (defined further down this file)
   already exists for exactly this, but nothing pairs it with a text
   label as one reusable unit -- every current "loading" message on the
   product surface (e.g. portal.html's Telegram panel: "Checking your
   Telegram connection...") is bare, unstyled text with no spinner at
   all. For a full-panel first-load state (not a single inline row),
   prefer .skeleton-block (defined further down this file) instead --
   this is specifically for a short "waiting on one async thing" line. */
.loading-inline {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-body);
  font-size: var(--text-base);
  color: var(--text-tertiary);
}

/* ---------- Public site nav ---------- */
.site-nav {
  position: sticky;
  top: 0;
  z-index: var(--z-sticky);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 32px;
  border-bottom: 1px solid var(--glass-border);
  background: rgba(14, 17, 20, 0.72);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
}
/* ---------- Brand wordmark (shared: nav, login, dashboard) ---------- */
/* "NEO" carries the mark at full weight in the display face; the second
   word rides beside it as a lighter, tracked-out mono label — a lockup,
   not just a name dropped in as plain text. */
.brand-mark {
  display: inline-flex;
  align-items: baseline;
  gap: 7px;
  text-decoration: none;
}
.brand-mark-icon { align-self: center; flex-shrink: 0; }
.brand-mark .brand-primary {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text-primary);
}
.brand-mark .brand-secondary {
  font-family: var(--font-mono);
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}
.site-nav .brand-mark .brand-primary { font-size: 1.15rem; }
.site-nav .brand-mark .brand-secondary { font-size: 0.66rem; padding-top: 2px; }

.site-nav .nav-links {
  display: flex;
  /* Tightened from 28px when FAQ was added as a sixth item (2026-08-19)
     — keeps the full list fitting on one line up to the same 720px
     collapse point rather than pushing that breakpoint wider. */
  gap: 21px;
  list-style: none;
  margin: 0;
  padding: 0;
}
.site-nav .nav-links a {
  position: relative;
  text-decoration: none;
  color: var(--text-secondary);
  font-size: 0.89rem;
  padding-bottom: 3px;
  transition: color 0.15s ease;
}
.site-nav .nav-links a::before {
  content: "";
  position: absolute;
  inset: -7px -10px;
  border-radius: 8px;
  background: var(--nav-hover-tint);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity 0.2s ease;
  z-index: -1;
}
.site-nav .nav-links a:hover::before { opacity: 1; }
.site-nav .nav-links a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 100%;
  bottom: -1px;
  height: 1px;
  background: var(--violet);
  transition: right 0.2s ease;
}
.site-nav .nav-links a:hover,
.site-nav .nav-links a.active {
  color: var(--text-primary);
}
.site-nav .nav-links a:hover::after,
.site-nav .nav-links a.active::after {
  right: 0;
}
.site-nav .nav-right {
  display: flex;
  align-items: center;
  gap: 14px;
}

/* Below this width the full link list no longer fits the header and was
   overflowing the viewport (dragging the whole page into horizontal
   scroll — found via a real narrow-viewport screenshot, not assumed).
   Kept at the original, already-tuned 720px cutoff rather than pushed
   wider when FAQ was added as a sixth nav-links item (2026-08-19) — see
   the tightened gap/font-size on .nav-links above, which was the fix
   used instead so the existing brand+Login-only range (641-720px)
   didn't get any wider than it already was. */
@media (max-width: 720px) {
  .site-nav .nav-links { display: none; }
  .site-nav { padding: 10px 14px; justify-content: flex-start; gap: 8px; }
  .site-nav .nav-right { margin-left: auto; }
}

/* PUBLIC MOBILE NAVIGATION (Neo UX Day 4, 2026-09-04) — the ".nav-links
   { display: none }" rule above previously left no primary-nav
   replacement below 720px other than the two-link .mobile-cta bar
   (Demo/Contact only) and the footer nav (reachable only after
   scrolling to the very bottom of every page) — a real, confirmed gap
   (Day 1 UX inventory), not assumed. This block adds a standard
   off-canvas drawer, deliberately modeled on the already-shipped,
   already axe-tested dashboard/admin mobile drawer (see style.css's own
   "MOBILE NAV DRAWER" comment near .dash-sidebar for the precedent) for
   its interaction mechanics (role="dialog" added only while open, focus
   trap, Escape, backdrop click, 44px targets, focus-visible reuse) —
   NOT copy-pasted, since this is a different shell (no persistent
   desktop sidebar to repurpose; this drawer only ever exists at this
   breakpoint). Reuses this file's own --z-sticky/--z-overlay/
   --focus-ring/--elevation-modal tokens; no new tokens introduced. See
   templates/base.html's own inline <script> for the open/close/focus-
   trap logic and templates/base.html markup for
   #site-mobile-menu-toggle/#site-nav-drawer/#site-mobile-backdrop/
   #site-drawer-close. */
/* BUG FIX (homepage visual reset review, 2026-09-04): only the toggle
   button had a default (i.e. above-720px) `display: none` here --
   .site-mobile-backdrop/.site-nav-drawer/.site-drawer-close had no
   declaration at all outside the media query below, so at desktop width
   they fell back to plain browser default block/button rendering and
   appeared as ordinary in-flow content at the top of every public page
   (found via an actual full-page desktop screenshot during this pass,
   not assumed -- the "×" close button and the full nav-links list were
   visibly stacked above the hero). The dashboard/admin drawer's
   equivalent rule (style.css, "MOBILE NAV DRAWER" section) already
   combines all of its off-canvas elements into one default-hidden
   selector list for exactly this reason -- this fixes the public
   drawer to match that same, already-correct pattern. */
.site-mobile-menu-toggle,
.site-mobile-backdrop,
.site-nav-drawer,
.site-drawer-close {
  display: none;
}
@media (max-width: 720px) {
  .site-mobile-menu-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    appearance: none;
    border: none;
    background: none;
    color: var(--text-secondary);
    width: 44px;
    height: 44px;
    padding: 0;
    flex-shrink: 0;
    cursor: pointer;
    border-radius: var(--radius-sm, 5px);
  }
  .site-mobile-menu-toggle:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
  .site-mobile-menu-toggle:focus-visible { outline: none; box-shadow: var(--focus-ring); }
  .site-mobile-menu-toggle svg { width: 22px; height: 22px; }

  .site-mobile-backdrop {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(5, 6, 8, 0.72);
    z-index: var(--z-sticky);
  }
  .site-mobile-backdrop.hidden { display: none; }
  body.site-drawer-locked { overflow: hidden; }

  .site-nav-drawer {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    width: min(84vw, 300px);
    max-width: 300px;
    z-index: var(--z-overlay);
    background: #0c0e11;
    border-right: 1px solid var(--border-subtle);
    display: flex;
    flex-direction: column;
    padding: 14px;
    transform: translateX(-100%);
    /* visibility (not just the transform) removes every descendant from
       the tab order while closed — same reasoning as the dashboard
       drawer's identical rule: without this a keyboard user tabbing
       past the toggle would tab straight into off-canvas drawer links
       before reaching the page's real content. */
    visibility: hidden;
    transition: transform 0.2s ease, visibility 0s linear 0.2s;
    box-shadow: var(--elevation-modal);
  }
  .site-nav-drawer.open {
    transform: translateX(0);
    visibility: visible;
    transition: transform 0.2s ease, visibility 0s linear 0s;
  }
  .site-drawer-close {
    display: flex;
    align-items: center;
    justify-content: center;
    align-self: flex-end;
    appearance: none;
    border: none;
    background: none;
    color: var(--text-secondary);
    font-size: 1.4rem;
    line-height: 1;
    width: 44px;
    height: 44px;
    cursor: pointer;
    border-radius: var(--radius-sm, 5px);
  }
  .site-drawer-close:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
  .site-drawer-close:focus-visible { outline: none; box-shadow: var(--focus-ring); }

  .site-drawer-links {
    list-style: none;
    margin: 8px 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .site-drawer-links a {
    display: flex;
    align-items: center;
    min-height: 44px;
    padding: 0 10px;
    color: var(--text-secondary);
    text-decoration: none;
    border-radius: 5px;
    font-size: 0.92rem;
  }
  .site-drawer-links a:hover,
  .site-drawer-links a.active { color: var(--text-primary); background: var(--nav-hover-tint); }
  .site-drawer-links a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 5px; }

  .site-drawer-account {
    margin-top: auto;
    padding-top: 14px;
    border-top: 1px solid var(--border-subtle);
  }
  .site-drawer-account a {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    color: var(--text-primary);
    text-decoration: none;
    border: 1px solid var(--border-strong);
    border-radius: 6px;
    font-size: 0.9rem;
  }
  .site-drawer-account a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 6px; }
}
.btn-login {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 8px 18px;
  border: 1px solid var(--violet-soft);
  border-radius: var(--radius-pill);
  color: var(--text-primary);
  background: var(--btn-login-tint);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  text-decoration: none;
  font-size: 0.88rem;
  font-weight: 600;
  white-space: nowrap;
  touch-action: manipulation;
  transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
/* State signaled by a lightness shift (tint -> solid button color), not
   a glow -- the homepage's primary CTA is this site's one deliberate
   glow moment (see .btn-primary:hover); every other hover/selected
   state uses border-color/background lightness instead (design-audit
   finding: glow-on-hover was repeated on nearly every interactive
   element sitewide). */
.btn-login:hover { background: var(--violet-button); transform: translateY(-1px); }

/* ---------- Page shell ---------- */
main { flex: 1; }
.container {
  max-width: 1040px;
  margin: 0 auto;
  padding: 56px 24px 80px;
}
.container.narrow { max-width: 560px; }

/* ---------- Footer (site-wide, _partials/footer.html) -- v2,
   2026-08-19: the prior two passes fixed width and added the social
   row, but the composition itself was still the default "brand-left /
   two-equal-nav-columns / centered copyright" SaaS-footer skeleton --
   giving Legal's 3 links the exact same visual weight (heading, gap,
   column treatment) as Navigate's 6 is what actually read as generic,
   independent of any spacing/color polish. This pass changes the
   composition, not just its styling: Legal moves into the bottom bar
   next to the copyright line (correctly subordinate -- it's compliance
   boilerplate, not primary navigation), and the remaining single
   Navigate column is set off from the brand block by a hairline
   vertical divider instead of bare gap, for the "crisp alignment"
   look a plain gap doesn't give. Still .container's own 1040px
   width -- no wider footer container than the rest of the site. ---------- */
.site-footer {
  border-top: 1px solid var(--border-subtle);
  padding: 40px 32px 24px;
  color: var(--text-tertiary);
}
.footer-inner {
  max-width: 1040px;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 48px;
  padding-bottom: 22px;
}
.footer-brand .brand-mark { margin-bottom: 10px; }
.footer-tagline { margin: 0; font-size: 0.85rem; color: var(--text-tertiary); max-width: 300px; line-height: 1.55; }
/* Icon-only, no button chrome (no fill, no border, no pill) -- this is
   the one place the anti-slop brief is explicit that social links must
   stay bare glyphs, not styled buttons. Padding exists purely to reach
   a real touch target (34px) without the icon itself looking larger
   than the footer nav text next to it. */
.footer-social { display: flex; gap: 2px; margin-top: 14px; }
.footer-social-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border-radius: var(--radius-xs);
  color: var(--text-tertiary);
  transition: color 0.15s ease, transform 0.15s ease;
}
/* Touch-target remediation (Phase 7, 2026-09-05): the visible 34x34
   icon/spacing is unchanged (measured, real UX evidence -- see the
   Phase 7 report) -- this pseudo-element only extends the actual
   clickable/hoverable area by 5px on every side (34+5+5=44) without
   enlarging the painted icon or the tight 2px row gap it sits in. */
.footer-social-link::after {
  content: "";
  position: absolute;
  inset: -5px;
}
.footer-social-link svg { width: 17px; height: 17px; fill: currentColor; }
.footer-social-link:hover { color: var(--violet); transform: translateY(-1px); }
.footer-nav {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-width: 120px;
  padding-left: 40px;
  border-left: 1px solid var(--border-subtle);
}
.footer-col-heading {
  /* Matches .eyebrow's color exactly (font-size/letter-spacing/margin
     stay smaller -- a footer heading, not a page eyebrow) -- the prior
     --text-secondary gray read as muted next to every other section
     label on the redesigned pages, which all use this teal accent. */
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--violet);
  margin-bottom: 4px;
}
.footer-nav a { color: var(--text-tertiary); text-decoration: none; font-size: 0.85rem; transition: color 0.15s ease; }
.footer-nav a:hover { color: var(--text-primary); }
@media (max-width: 560px) {
  /* A left border reads as a divider only in a side-by-side layout --
     once .footer-inner wraps and the nav column drops below the brand
     block, flip it to a top border (and swap the padding to match) so
     it still reads as "a new section starts here," not a stray rule
     down one side of a stacked block. */
  .footer-nav {
    padding-left: 0;
    padding-top: 20px;
    border-left: none;
    border-top: 1px solid var(--border-subtle);
  }
}
.footer-bottom {
  max-width: 1040px;
  margin: 0 auto;
  padding-top: 18px;
  border-top: 1px solid var(--border-subtle);
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 0.74rem;
  font-family: var(--font-mono);
  color: var(--text-tertiary);
}
.footer-legal { display: flex; align-items: center; gap: 8px; }
.footer-legal a { color: var(--text-tertiary); text-decoration: none; transition: color 0.15s ease; }
.footer-legal a:hover { color: var(--text-primary); }
.footer-legal span { color: var(--border-strong); }
@media (max-width: 480px) {
  .footer-bottom { flex-direction: column; align-items: flex-start; gap: 10px; }
}

/* ---------- Hero (home page) — the one signature moment ---------- */
/* Asymmetric two-column layout, not a centered template block: copy
   left, a moody environment photo + the live HUD panel stacked right. */
.hero-section {
  position: relative;
  overflow: hidden;
  padding: 72px 24px 88px;
  background-image:
    radial-gradient(ellipse 620px 460px at 82% 20%, var(--violet-glow), transparent 68%),
    radial-gradient(circle, var(--border-subtle) 1px, transparent 1px);
  background-size: auto, 26px 26px;
  background-position: center, center;
}
.hero-inner { position: relative; max-width: 1140px; margin: 0 auto; }
.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
  gap: 56px;
  align-items: center;
}
.hero-copy { text-align: left; }
.hero-visual-stack { display: flex; flex-direction: column; gap: 20px; }

.hero-visual {
  position: relative;
  border-radius: 14px;
  overflow: hidden;
  border: 1px solid var(--border-strong);
  aspect-ratio: 4 / 5;
}
.hero-visual img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  filter: grayscale(35%) brightness(0.55) contrast(1.15);
}
.hero-visual::after {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(8, 9, 11, 0) 35%, var(--bg-void) 100%),
    linear-gradient(115deg, var(--hero-visual-tint), transparent 55%);
  pointer-events: none;
}

@media (max-width: 900px) {
  .hero-grid { grid-template-columns: 1fr; }
}

/* .hero-badge removed (Phase 2 homepage cleanup) -- confirmed unused by
   any template even before this pass; dead code from a previous design. */
@keyframes pulse-glow {
  0%   { box-shadow: 0 0 0 0 var(--violet-glow); }
  70%  { box-shadow: 0 0 0 8px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}

.hero-section h1 {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(2rem, 4.4vw, 2.9rem);
  margin: 0 0 20px;
  color: var(--text-primary);
  line-height: 1.14;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
  min-width: 0;
  text-wrap: balance;
}

/* .hero-video-section / .hero-bg-video / .hero-bg-overlay / .hero-corner-tag
   and the character-by-character hero-anim.js entrance animation removed
   (Phase 2 homepage rewrite) -- confirmed homepage-exclusive (no other
   template referenced them), and the letter-stagger reveal itself was
   exactly the kind of premium-SaaS entrance flourish the new homepage's
   motion strategy rules out. See .hp-hero below for the replacement. */
p.lead {
  color: var(--text-secondary);
  font-size: 1.05rem;
  line-height: 1.65;
  max-width: 480px;
  margin: 0 0 32px;
}
.hero-actions { display: flex; gap: 14px; justify-content: flex-start; flex-wrap: wrap; }

.btn-primary, .btn-secondary {
  display: inline-block;
  padding: 11px 24px;
  border-radius: 6px;
  text-decoration: none;
  font-size: 0.92rem;
  font-weight: 600;
  border: 1px solid transparent;
  /* Buttons render on one line, never an uneven 2-line wrap at narrow
     mobile widths (320-375px) -- the two longest CTA labels sitewide
     were also shortened to comfortably fit at that width instead of
     relying on nowrap alone to force it. */
  white-space: nowrap;
  touch-action: manipulation;
  transition: box-shadow 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.btn-primary { background: var(--violet-button); color: var(--text-primary); }
/* This site's one deliberate glow moment (design-audit finding: keep at
   most one glowing hover state sitewide, on the single most important
   action) -- every other button/card/selected-state hover below uses a
   plain border-color or lightness shift instead. */
.btn-primary:hover { box-shadow: 0 0 24px var(--violet-glow); transform: translateY(-1px); }
.btn-secondary { border-color: var(--border-strong); color: var(--text-primary); }
.btn-secondary:hover { border-color: var(--text-tertiary); transform: translateY(-1px); }

/* HUD status readout — real config values, not decorative stats */
.hud-panel {
  position: relative;
  overflow: hidden;
  text-align: left;
  border: 1px solid var(--border-strong);
  background: linear-gradient(180deg, rgba(21, 24, 29, 0.9), rgba(16, 18, 22, 0.9));
  border-radius: 10px;
  padding: 4px 0;
}
/* Canonical liquid-glass surface: backdrop-blur, a soft top/bottom inset
   highlight standing in for a literal gradient border (a true
   `border-image` gradient clips square through `border-radius` in every
   engine, which would break every rounded glass panel on the site — this
   inset-highlight trick reads as a gradient-lit edge without that cost),
   and an inset shadow for depth. Used wherever a panel floats over
   varied/bright content (the tech-stack panel, the video hero's corner
   tag) — other HUD panels (Home, Projects) intentionally stay flat/solid,
   per this file's visual-effects scope note above. */
.glass, .hud-panel.tech-panel {
  border-color: var(--glass-border);
  background: var(--glass-bg);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  /* One shadow value, not four stacked layers (design-audit finding) --
     the single top inset highlight is what actually reads as a
     gradient-lit glass edge; the extra bottom-tint/ambient-glow/outer-
     drop layers this used to also carry added cost without a
     perceptible visual difference. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
  transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.glass:hover, .hud-panel.tech-panel:hover { border-color: var(--glass-border-hover); }
.hud-panel::after {
  content: "";
  position: absolute;
  left: 0; right: 0; height: 40%;
  background: linear-gradient(180deg, transparent, var(--violet-glow), transparent);
  opacity: 0.5;
  animation: scan-sweep 4.5s linear infinite;
}
@keyframes scan-sweep {
  0%   { transform: translateY(-100%); }
  100% { transform: translateY(350%); }
}
/* Homepage-only override: this session's anti-slop audit classified the
   scan-sweep as purely decorative (an ambient glow band that runs
   forever regardless of real state). About and Projects still use the
   same .hud-panel component and keep it -- this scopes the removal to
   .home-body specifically rather than touching the shared rule above. */
.home-body .hud-panel::after { content: none; animation: none; }
.hud-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 18px;
  border-bottom: 1px solid var(--border-subtle);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}
.hud-panel-header .hud-live { color: var(--violet); }
.hud-row {
  display: flex;
  justify-content: space-between;
  padding: 10px 18px;
  font-family: var(--font-mono);
  font-size: 0.82rem;
  border-bottom: 1px solid var(--border-subtle);
}
.hud-row:last-child { border-bottom: none; }
.hud-label { color: var(--text-tertiary); }
.hud-value { color: var(--text-primary); font-weight: 500; }
/* Homepage HUD panel only: its values render once at request time and
   never refresh client-side (unlike the dashboard's polled system panel),
   so the panel says so plainly instead of letting the "● active" pulse
   imply a live feed it isn't. */
.hud-panel-footnote {
  padding: 10px 18px 12px;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  color: var(--text-tertiary);
}
.hud-panel-footnote a { color: var(--violet-soft); }

/* HUD panel variant: tech stack (About page) — grouped rows instead of
   label/value pairs, same panel chrome (border, scan-line, mono header). */
.hud-panel.tech-panel { max-width: 100%; padding: 4px 0 8px; }
.hud-category {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  padding: 16px 18px 6px;
}
.hud-tech-row { padding: 10px 18px; border-bottom: 1px solid var(--border-subtle); }
.hud-tech-name {
  display: block;
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--text-primary);
  margin-bottom: 4px;
}
.hud-tech-desc { font-family: var(--font-body); color: var(--text-secondary); font-size: 0.85rem; line-height: 1.5; }

/* ---------- Infinite scroll marquee (About page tech strip) ---------- */
.marquee {
  overflow: hidden;
  border-top: 1px solid var(--border-subtle);
  border-bottom: 1px solid var(--border-subtle);
  padding: 18px 0;
  margin: 8px 0 40px;
  -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
  mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
}
.marquee-track {
  display: flex;
  gap: 14px;
  width: max-content;
  animation: marquee-scroll 32s linear infinite;
}
.marquee:hover .marquee-track { animation-play-state: paused; }
@keyframes marquee-scroll {
  0% { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}
.marquee-item {
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  color: var(--text-secondary);
  border: 1px solid var(--glass-border);
  background: var(--glass-bg);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  padding: 8px 16px;
  border-radius: 999px;
  white-space: nowrap;
}

/* ---------- Breadcrumb ---------- */
.breadcrumb {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-tertiary);
  margin-bottom: 22px;
  display: flex;
  align-items: center;
  gap: 8px;
}
.breadcrumb a { color: var(--text-tertiary); text-decoration: none; transition: color 0.15s ease; }
.breadcrumb a:hover { color: var(--violet); }
.breadcrumb-sep { color: var(--border-strong); }
.breadcrumb-current { color: var(--text-secondary); }

/* ---------- Section headers ---------- */
.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  margin-bottom: 10px;
}
.page-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.5rem, 5vw, 1.9rem);
  margin: 0 0 14px;
  color: var(--text-primary);
  overflow-wrap: anywhere;
  min-width: 0;
  text-wrap: balance;
}
.doc-revision-note {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-tertiary);
  margin: -6px 0 18px;
}
.doc-revision-note em { color: var(--text-secondary); font-style: normal; }
h2.section-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.3rem;
  color: var(--text-primary);
  margin: 56px 0 8px;
}
p.section-sub { color: var(--text-tertiary); margin: 0 0 28px; font-size: 0.92rem; }

/* Legal/policy pages (privacy, terms, cookie policy) use a real h2 for
   each card subsection instead of skipping straight from h1 to h3 —
   correct heading hierarchy, kept visually compact (not the much larger
   default h2 size) since these sit inside a dense .form-card panel
   rather than acting as a full page section break. */
.form-card h2 {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 1.05rem;
  margin: 0 0 10px;
  color: var(--text-primary);
}
.form-card h2:not(:first-child) { margin-top: 22px; }

/* ---------- Cards (features / tech / projects) ---------- */
.card-grid {
  display: grid;
  /* Capped max (not 1fr) so a single-item row — e.g. one ongoing project
     — doesn't stretch a card (and its image) to the full container width. */
  grid-template-columns: repeat(auto-fit, minmax(230px, 340px));
  gap: 16px;
}
.card {
  border: 1px solid var(--border-subtle);
  background: var(--bg-surface);
  border-radius: 10px;
  overflow: hidden;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.card:hover {
  border-color: var(--violet-soft);
  box-shadow: 0 8px 32px -12px rgba(0, 0, 0, 0.5);
  transform: translateY(-3px);
}

/* Elements static/js/tilt.js applies a mousemove-driven perspective
   tilt to. Kept as a distinct opt-in class (see tilt.js) rather than
   hooking .card directly, so it stays easy to deploy on specific
   elements per page instead of blanket-applying everywhere. */
.tilt-target { transform-style: preserve-3d; will-change: transform; }
.card-image { position: relative; aspect-ratio: 16 / 10; overflow: hidden; }
.card-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  filter: grayscale(30%) brightness(0.6) contrast(1.1);
}
.card-image::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, transparent 40%, var(--bg-surface) 100%);
}
.card-body { padding: 20px; }
.card .card-eyebrow {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--violet);
  margin-bottom: 8px;
  font-weight: 600;
}
/* Clash Display is a large-headline face — at small sizes (this was
   1rem) its bold geometric strokes overlap into a muddy, strikethrough-
   like smear. Body face for card titles; display face stays reserved
   for actual headline sizes (hero h1, page-title, section-title). */
.card h3 { font-family: var(--font-body); font-weight: 600; margin: 0 0 8px; font-size: 1rem; color: var(--text-primary); }
.card p { margin: 0; color: var(--text-secondary); font-size: 0.88rem; line-height: 1.55; }

/* .visual-grid/.visual-tile/.visual-credit removed (Phase 2 homepage
   rewrite) -- the generic Unsplash hallway photography they wrapped is
   cut per the new homepage's image strategy (couldn't be shown without
   implying a real deployment); confirmed homepage-exclusive. */

/* ---------- Projects (card-grid, see .card / .card-image / .card-body) ---------- */
.status-pill {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  margin-bottom: 10px;
}
.status-pill.ongoing { background: rgba(16, 185, 129, 0.12); color: var(--tier-authorized); }
.status-pill.completed { background: rgba(138, 143, 152, 0.15); color: var(--text-secondary); }
.project-meta-line { margin-top: 10px; color: var(--text-tertiary); font-size: 0.78rem; }

/* .feature-groups/.feature-group/.feature-point/.feature-badge,
   .showcase-tier*, and .closing-band/.closing-cta-lead removed (Phase 2
   homepage rewrite) -- confirmed homepage-exclusive, replaced by
   .evidence-ledger/.evidence-row (content-led, no badge/checkmark) and
   .hp-closing below. .inline-cta-link is kept -- still used by the new
   homepage's founder section. */
.inline-cta-link {
  color: var(--violet-soft);
  text-decoration: none;
  font-size: 0.9rem;
  border-bottom: 1px solid var(--violet-glow);
  padding-bottom: 1px;
  transition: color 0.15s ease;
}
.inline-cta-link:hover { color: var(--text-primary); }

/* ==================================================================
   Home (Phase 2 rewrite, 2026-08-18; visual correction pass,
   2026-08-18) — content-led claim/evidence homepage. Deliberately
   reuses shared components where they already fit (.eyebrow,
   h2.section-title/p.section-sub, .hud-panel, .inline-cta-link,
   .hero-actions/.btn-primary/.btn-secondary) rather than inventing
   parallel ones. Everything below is new and homepage-exclusive. No
   gradients, no glow/blur, no animation beyond .reveal's existing fade
   (see reveal.js) -- see this session's anti-slop audit.

   Correction pass: the first cut was too visually quiet -- restrained
   read as "documentation page," not "confident product." This version
   keeps every anti-slop rule but makes the REAL content (headline, real
   screenshot, real pipeline, real evidence) carry dramatically more
   visual weight via type scale and whitespace, not decoration. ---------- */

/* ---------- Hero: asymmetric two-column, real screenshot as the
   visual weight (no separate "evidence" section below the fold) ---------- */
.hp-hero {
  padding: 64px 24px 88px;
  border-bottom: 1px solid var(--border-subtle);
}
.hp-hero-grid {
  max-width: 1240px;
  margin: 0 auto;
  display: grid;
  /* Pixel-precision implementation (2026-09-04): was minmax(0,1.05fr)
     minmax(0,0.95fr) -- copy slightly WIDER than the real screenshot.
     Flipped so the one real piece of proof this page has gets more
     weight than the text beside it, per the approved art direction's
     "let the image be the visual anchor" instruction. Rendered geometry
     measured and reported in the implementation report, not asserted
     here. */
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr);
  gap: 56px;
  align-items: center;
}
.hp-hero-copy { max-width: 560px; }
/* .hp-status-badge/.hp-status-dot removed (homepage visual reset,
   2026-09-04) -- the rounded-pill "eyebrow" badge was exactly the kind
   of decorative chip this pass identified as an anti-pattern. The hero
   eyebrow now reuses the plain, borderless .eyebrow component (below,
   also used on about.html/projects.html) instead of its own component. */
.hp-hero-headline {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(2.75rem, 5vw, 4.25rem);
  margin: 0 0 24px;
  color: var(--text-primary);
  line-height: 1.04;
  letter-spacing: -0.02em;
  overflow-wrap: anywhere;
  min-width: 0;
  text-wrap: balance;
}
.hp-hero-lead {
  font-size: 1.15rem;
  line-height: 1.62;
  max-width: 560px;
  margin-bottom: 40px;
}
.hp-hero-evidence {
  margin: 0;
  border: 1px solid var(--border-subtle);
  border-radius: 12px;
  overflow: hidden;
  background: var(--bg-surface);
}
.hp-hero-evidence img { display: block; width: 100%; height: auto; }
.hp-hero-evidence-caption {
  padding: 14px 18px;
  font-size: 0.8rem;
  /* was --text-tertiary (#777e86 on this panel's #12161b background =
     4.42:1, just under WCAG AA's 4.5:1 minimum for this font size --
     found via an automated axe-core scan). --text-secondary passes
     comfortably and reads as only a slightly less muted supporting
     caption, not a visual change in intent. */
  color: var(--text-secondary);
  border-top: 1px solid var(--border-subtle);
}
.hp-hero-evidence-caption code {
  font-family: var(--font-mono);
  color: var(--text-secondary);
  background: var(--bg-void);
  padding: 1px 6px;
  border-radius: 4px;
  font-size: 0.85em;
}
.hp-hero-evidence-caption a { color: var(--violet-soft); }
@media (max-width: 980px) {
  .hp-hero-grid { grid-template-columns: 1fr; gap: 40px; }
  .hp-hero-copy { max-width: none; }
}

/* Live-site finding (2026-08-21): at narrow mobile widths, the hero's
   copy block was tall enough that its last line of body text sat behind
   the fixed cookie-consent banner + sticky mobile-CTA bar stack on
   first paint (before either is dismissed/scrolled past) -- confirmed
   via real DOM measurement, not assumed. Tightening the hero's own
   vertical rhythm at this breakpoint (smaller headline/lead sizes,
   tighter margins, less hero padding) is the fix, rather than adding
   dead space elsewhere: it gives the same content meaningfully more
   headroom above that fixed-overlay zone instead of just pushing the
   problem down the page. */
@media (max-width: 480px) {
  .hp-hero { padding: 40px 20px 32px; }
  .hp-hero .eyebrow { margin-bottom: 16px; }
  .hp-hero-headline { font-size: 1.85rem; margin-bottom: 14px; }
  .hp-hero-lead { font-size: 0.96rem; line-height: 1.55; margin-bottom: 24px; }
}

/* ---------- Section rhythm: deliberately not uniform -- see each
   section's own comment below for its intended visual weight. ---------- */
.hp-section { margin-bottom: 96px; }
.hp-title-lg { font-size: clamp(1.7rem, 2.8vw, 2.35rem); }
.hp-lead-md { font-size: 1.08rem; max-width: 660px; line-height: 1.6; }

/* ---------- How it works: a genuinely readable vertical sequence, not
   six compressed cards. Numbers + a "↓" mark the sequence; hairline
   rules do the separating -- no connecting-line graphic needed. ---------- */
.pipeline-diagram {
  display: flex;
  flex-direction: column;
  margin: 44px 0 0;
  padding: 0;
  list-style: none;
  max-width: 780px;
}
.pipeline-stage {
  display: grid;
  grid-template-columns: 60px 1fr;
  column-gap: 24px;
  padding: 30px 0;
  border-top: 1px solid var(--border-subtle);
}
.pipeline-stage:first-child { border-top: none; padding-top: 8px; }
.pipeline-stage-num {
  font-family: var(--font-mono);
  font-size: 1.15rem;
  font-weight: 500;
  color: var(--text-tertiary);
  line-height: 1.3;
}
.pipeline-stage:not(:last-child) .pipeline-stage-num::after {
  content: "↓";
  display: block;
  margin-top: 14px;
  color: var(--border-strong);
  font-size: 1rem;
}
.pipeline-stage-body { display: flex; flex-direction: column; gap: 8px; }
.pipeline-stage-name {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.2rem, 1.8vw, 1.45rem);
  color: var(--text-primary);
  line-height: 1.25;
}
.pipeline-stage-detail {
  font-family: var(--font-mono);
  font-size: 0.88rem;
  color: var(--text-secondary);
  line-height: 1.5;
}
/* Single real photo, stage 01 only (the two physical cameras CAM-01/
   CAM-02 the detail line above already names) -- quiet and subordinate
   to the text: capped width, natural aspect ratio preserved (no crop,
   no object-fit), same border/radius tokens every other bordered
   element on this page already uses. Not a new visual pattern -- no
   other stage gets one. */
.pipeline-stage-photo {
  display: block;
  width: 100%;
  max-width: 220px;
  height: auto;
  margin-top: 4px;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm, 5px);
}
/* Tier swatches: real semantic-state color (same tokens the dashboard's
   alert log / roster use), not decoration -- see detection.py's
   TIER_COLORS_HEX, which these mirror. */
.pipeline-tier-swatches { display: flex; flex-wrap: wrap; align-items: center; }
.tier-swatch {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 6px;
}
.tier-swatch:not(:first-child) { margin-left: 12px; }
.tier-swatch-authorized { background: var(--tier-authorized); }
.tier-swatch-borderline { background: var(--tier-borderline); }
.tier-swatch-unknown { background: var(--tier-unknown); }

.hp-footnote {
  margin: 28px 0 0;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  line-height: 1.65;
  color: var(--text-tertiary);
  max-width: 700px;
}

@media (max-width: 480px) {
  .pipeline-stage { grid-template-columns: 40px 1fr; column-gap: 16px; }
}

/* ---------- Verified evidence: an editorial ledger. The claim is the
   visual anchor of each row; context and verification step down in
   size/weight/color -- deliberately no badge/pill/checkmark. ---------- */
.evidence-ledger { display: flex; flex-direction: column; margin-top: 12px; }
.evidence-row {
  padding: 40px 0;
  border-top: 1px solid var(--border-subtle);
}
.evidence-row:first-child { border-top: none; padding-top: 16px; }
.evidence-claim {
  margin: 0 0 14px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.35rem, 2.2vw, 1.7rem);
  color: var(--text-primary);
  line-height: 1.3;
  max-width: 760px;
}
.evidence-context {
  margin: 0 0 16px;
  max-width: 680px;
  font-size: 1.02rem;
  color: var(--text-secondary);
  line-height: 1.65;
}
.evidence-verification {
  margin: 0;
  max-width: 680px;
  font-family: var(--font-mono);
  font-size: 0.8rem;
  color: var(--text-tertiary);
  line-height: 1.6;
}
.evidence-verification-label {
  color: var(--text-tertiary);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.68rem;
  margin-right: 8px;
}

/* ---------- Honest scope: a strong statement, not a footnote. The
   supporting list stays plain/small -- the statement carries the
   section's visual weight. ---------- */
.hp-scope-statement {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.55rem, 2.8vw, 2.15rem);
  line-height: 1.32;
  color: var(--text-primary);
  max-width: 780px;
  margin: 0 0 36px;
}
.hp-scope-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; max-width: 700px; }
.hp-scope-list li {
  padding: 15px 0;
  border-top: 1px solid var(--border-subtle);
  font-size: 0.95rem;
  color: var(--text-secondary);
  line-height: 1.6;
}
.hp-scope-list li:first-child { border-top: none; }

/* ---------- Security architecture: three-way real status split,
   presented as a structured comparison (top rule + typography), not
   three bordered cards. ---------- */
.security-grid {
  display: grid;
  /* auto-fit rather than a fixed repeat(3, 1fr): the middle group
     (security-group-reviewed) is conditionally rendered in home.html and
     currently empty (see site_content.py's HOME_SECURITY_ARCHITECTURE
     correction, 2026-08-19) -- a fixed 3-column track would leave a
     blank column instead of letting the remaining groups fill the row. */
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 40px;
  margin-top: 12px;
}
.security-group {
  border-top: 2px solid var(--border-strong);
  padding-top: 20px;
}
.security-group-label {
  font-family: var(--font-mono);
  font-size: 0.84rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  margin: 0 0 18px;
}
/* Real tier-status colors reused for real deployment status -- same
   semantic-color principle as the pipeline diagram's tier swatches. */
.security-group-deployed .security-group-label { color: var(--tier-authorized); }
.security-group-reviewed .security-group-label { color: var(--tier-borderline); }
.security-group-not-implemented .security-group-label { color: var(--text-tertiary); }
.security-group ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 14px; }
.security-group li {
  font-size: 0.94rem;
  color: var(--text-secondary);
  line-height: 1.6;
  padding-left: 16px;
  position: relative;
}
.security-group li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.6em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--border-strong);
}
@media (max-width: 800px) {
  .security-grid { grid-template-columns: 1fr; gap: 32px; }
}

/* ---------- What's next: a small forward-looking beat between Security
   Architecture and Founder/Stage -- deliberately subordinate to the
   major evidence sections (smaller margin than .hp-section, smaller
   type than the Honest Scope statement or Founder text below). No
   card/border/icon/gradient -- just the eyebrow + one paragraph. ---------- */
.hp-whats-next {
  max-width: 720px;
  margin: 0 0 64px;
}
.hp-whats-next-statement {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 1.1rem;
  color: var(--text-secondary);
  line-height: 1.6;
  margin: 0;
}

/* ---------- Founder / stage: an editorial statement, not a leftover
   paragraph -- larger, medium-weight display type. Pixel-precision
   implementation (2026-09-04): wrapped in .hp-founder-section (below)
   for extra breathing room on both sides -- the approved art direction
   calls this out by name as the one section that should get more air
   than its neighbors, not the same --space-8 rhythm as every other
   section. The closing link also drops the " →" suffix the other two
   sections use (see home.html) -- a real, if small, ending difference
   rather than a third instance of the same arrow-link pattern. ---------- */
.hp-founder-section { margin-top: var(--space-9); margin-bottom: var(--space-9); }
.hp-founder-text {
  max-width: 760px;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(1.2rem, 1.9vw, 1.45rem);
  color: var(--text-primary);
  line-height: 1.55;
  margin: 0 0 22px;
}
/* 44px touch target (pixel-precision implementation, 2026-09-04):
   .inline-cta-link's base rule (above) is a plain inline text link with
   no explicit box height -- pre-existing sitewide, not introduced here.
   The two new Home CTA links (.hp-steps/.hp-facts) and the founder link
   this pass touches are scoped up to the minimum explicitly, matching
   this codebase's own established min-height pattern (see admin.css's
   identical fix for the camera action row) rather than changing
   .inline-cta-link globally, which is out of this pass's scope. */
.hp-section .inline-cta-link,
.hp-founder-cta {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}
.hp-founder-cta { font-size: 0.95rem; }

/* ---------- Closing: pixel-precision implementation (2026-09-04) --
   replaces the previous dark rounded CTA box (padding/border-radius/
   bg-surface/border, centered, two buttons) with a plain ending against
   the page background -- the approved art direction names that box
   specifically as "the single most recognizable SaaS landing-page
   ending pattern that exists." One sentence, one button, no card, no
   second competing action. ---------- */
.hp-closing {
  margin: var(--space-8) 0 var(--space-7);
  text-align: center;
}
.hp-closing-lead {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.4rem, 2.4vw, 1.8rem);
  color: var(--text-primary);
  margin: 0 0 24px;
}

/* ---------- System steps ("See what the system sees"): a typographic
   progression -- three columns separated by a hairline rule, not
   cards/icons/pills. Deliberately a DIFFERENT shape from .hp-facts
   below (columns + vertical rule here, stacked rows + horizontal rule
   there) so the two sections read as distinct compositions, not two
   copies of the same block with different words. ---------- */
.hp-steps {
  display: flex;
  gap: 0;
  margin: 40px 0 32px;
}
.hp-step {
  flex: 1 1 0;
  padding: 0 32px;
  border-left: 1px solid var(--border-subtle);
}
.hp-step:first-child { padding-left: 0; border-left: none; }
.hp-step-label {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.8rem;
  color: var(--text-tertiary);
  margin: 0 0 12px;
}
.hp-step-text {
  color: var(--text-secondary);
  font-size: 1rem;
  line-height: 1.6;
  margin: 0;
}
@media (max-width: 780px) {
  .hp-steps { flex-direction: column; gap: 24px; }
  .hp-step { padding: 20px 0 0; border-left: none; border-top: 1px solid var(--border-subtle); }
  .hp-step:first-child { padding-top: 0; border-top: none; }
}

/* ---------- Trust facts: stacked label/statement rows, horizontal
   rule -- see .hp-steps above for why this is deliberately a different
   shape (rows here, columns there). ---------- */
.hp-facts {
  margin: 32px 0 28px;
  border-top: 1px solid var(--border-subtle);
  max-width: 780px;
}
.hp-fact {
  display: flex;
  gap: 24px;
  padding: 20px 0;
  border-bottom: 1px solid var(--border-subtle);
}
.hp-fact-label {
  flex: 0 0 140px;
  color: var(--text-tertiary);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.85rem;
}
.hp-fact-statement {
  flex: 1;
  min-width: 0;
  color: var(--text-secondary);
  font-size: 1rem;
  line-height: 1.6;
  margin: 0;
}
@media (max-width: 640px) {
  .hp-fact { flex-direction: column; gap: 6px; }
  .hp-fact-label { flex: none; }
}

/* ---------- About page: human/technical register split (pixel-
   precision implementation, 2026-09-04). .about-evidence-band is a
   full-bleed subtle-surface tint distinguishing the technical/evidence
   register from the human/editorial register above it -- a real,
   perceivable difference (not just a heading) using an EXISTING token
   (--bg-surface, already used for panels elsewhere), not a new color.
   The human register above renders on the plain page background
   (--bg-void); this band is one step lighter, matching the same
   figure/ground relationship --bg-surface already has everywhere else
   on the site. No gradient, no second accent hue. ---------- */
.about-evidence-band {
  background: var(--bg-surface);
  border-top: 1px solid var(--border-subtle);
  border-bottom: 1px solid var(--border-subtle);
  padding-bottom: var(--space-8);
  margin-top: var(--space-8);
}
.about-evidence-label { padding-top: var(--space-8); margin-bottom: 0; }
/* Plain width-constraint wrappers (max-width + horizontal padding only,
   no vertical padding) for content inside/after the band -- .container
   itself carries its own 56px/80px vertical padding, which would
   otherwise stack with the band's own margin/padding at each boundary
   and produce unintentionally large gaps. */
.about-evidence-inner { max-width: 1040px; margin: 0 auto; padding: 0 24px; }
.about-closing-wrap { max-width: 1040px; margin: 0 auto; padding: var(--space-8) 24px 80px; }

/* Fact rows: plain label/value rows replacing the .hud-panel "system
   status" treatment for About's live-state section (pixel-precision
   implementation, 2026-09-04) -- real data does not require dashboard
   chrome. Values that ARE genuine machine/config data (model name,
   acceleration, tolerance, cooldown) keep --font-mono, per the approved
   art direction's "monospace only for genuine machine/data values"
   rule; there is no bordered panel, no gradient background, no
   "// system status" header string, no pulsing live dot, and no
   backdrop-blur -- and therefore also no .hud-panel::after scan-sweep
   animation (a perpetual glowing band that was running on this page
   indefinitely; removing the component removes the animation, not
   patched separately). */
.fact-rows { margin-top: 20px; max-width: 620px; border-top: 1px solid var(--border-subtle); }
.fact-row {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 0;
  border-bottom: 1px solid var(--border-subtle);
}
.fact-row-label { color: var(--text-tertiary); }
.fact-row-value { color: var(--text-primary); font-family: var(--font-mono); font-weight: 500; text-align: right; }
.fact-rows-footnote {
  margin: 14px 0 0;
  font-family: var(--font-body);
  font-size: 0.82rem;
  color: var(--text-tertiary);
  max-width: 620px;
}
.fact-rows-footnote a { color: var(--violet-soft); }

/* Tech list: replaces the tech-stack .hud-panel.tech-panel (which also
   carried the glassmorphism .glass treatment -- backdrop-filter blur,
   inset highlight -- removed entirely, not just the panel chrome). */
.tech-list-category {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 0.8rem;
  color: var(--text-tertiary);
  margin: 28px 0 4px;
}
.tech-list-category:first-child { margin-top: 0; }
.tech-list-row { padding: 10px 0; border-bottom: 1px solid var(--border-subtle); }
.tech-list-name {
  display: block;
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--text-primary);
  margin-bottom: 4px;
}
.tech-list-desc { font-family: var(--font-body); color: var(--text-secondary); font-size: 0.85rem; line-height: 1.5; }
.tech-list-count {
  font-family: var(--font-body);
  font-size: 0.78rem;
  color: var(--text-tertiary);
  margin: 4px 0 0;
}
.hp-hud-panel-lg .hud-panel-footnote { padding: 14px 22px 18px; font-size: 0.78rem; }

/* ---------- Forms (contact / login) ---------- */
/* Editorial restraint, not a default bootstrap form: mono tracked-out
   labels (matching the HUD/eyebrow vocabulary used everywhere else),
   quiet borders, a single violet accent on focus. */
.form-card {
  border: 1px solid var(--border-subtle);
  background: var(--bg-surface);
  border-radius: 10px;
  padding: 32px;
}
label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  margin: 0 0 8px;
}
input[type="text"], input[type="email"], input[type="password"], input[type="tel"], select, textarea {
  width: 100%;
  padding: 11px 13px;
  border-radius: 6px;
  border: 1px solid var(--border-strong);
  background: var(--bg-void);
  color: var(--text-primary);
  font-size: 0.92rem;
  font-family: var(--font-body);
  margin-bottom: 22px;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
/* ACCESSIBILITY FIX (independent design review, Phase 7, 2026-08-20):
   was var(--border-strong) (#333c45) -- a token meant for subtle
   borders, not text. Computed contrast against --bg-surface/--bg-void
   was ~1.6:1 (needs 4.5:1), making "(optional)" effectively invisible
   regardless of the uppercase label transform around it.
   --text-tertiary is the token this codebase already uses elsewhere for
   de-emphasized-but-still-readable text and computes to ~4.55:1.
   .field-hint is the same treatment under a name that doesn't imply
   "this field can be skipped" -- activate.html's password-length hint
   ("min 8 characters") is on a REQUIRED field; reusing .field-optional
   there was a semantic mismatch the same review flagged. */
.field-optional, .field-hint { color: var(--text-tertiary); font-weight: 400; }
/* :focus-visible (not plain :focus), and a real `outline` rather than a
   box-shadow standing in for one -- two related fixes (UI/a11y audit):
   plain :focus fired this ring on every mouse click into a field, not
   just keyboard navigation; box-shadow doesn't participate in
   forced-colors/high-contrast mode and shared the same transition
   property as any hover glow on the element, delaying its appearance.
   Border-color shift still applies on plain :focus (any focus method),
   since that alone isn't the part either audit flagged. */
input:focus, select:focus, textarea:focus {
  border-color: var(--violet-soft);
}
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: 2px solid var(--violet-soft);
  outline-offset: 1px;
}
textarea { resize: vertical; min-height: 120px; }
/* Native OS select popups can't be fully re-skinned without JS, but the
   closed-state box shouldn't show a mismatched system-chrome arrow on an
   otherwise fully custom dark UI — appearance:none + a hand-drawn chevron
   keeps that one detail on-brand. */
select {
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%238a8f98' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 13px center;
  padding-right: 36px;
  cursor: pointer;
}

button.btn-submit {
  width: 100%;
  padding: 13px;
  border: none;
  border-radius: 6px;
  background: var(--violet-button);
  color: var(--text-primary);
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
button.btn-submit:hover { background: var(--violet-soft); transform: translateY(-1px); }

.alert-banner {
  padding: 12px 16px;
  border-radius: 6px;
  font-size: 0.87rem;
  margin-bottom: 20px;
}
.alert-banner.success { background: rgba(63, 169, 160, 0.12); color: var(--violet); border: 1px solid rgba(63, 169, 160, 0.3); }
.alert-banner.error { background: rgba(255, 59, 78, 0.12); color: var(--red); border: 1px solid rgba(255, 59, 78, 0.3); }
/* Gap-fill (design-token consolidation pass, 2026-08-22): only
   success/error variants existed -- no page-level banner treatment for
   "this needs attention but isn't a failure" (e.g. a future
   StorageWarning/LicenseWarning surfaced to an operator) or a neutral
   informational notice existed, so every caller needing one would have
   had to invent a seventh one-off rgba() pair. Same shape, using the
   --tier-borderline/--text-tertiary tint tokens this pass added above. */
.alert-banner.warning { background: var(--tier-borderline-tint); color: var(--tier-borderline); border: 1px solid var(--tier-borderline-border); }
.alert-banner.info { background: var(--bg-surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); }

.hidden { display: none !important; }

/* ---------- Product-tier selection (request-access form, 2026-08-21) ----------
   Visual redesign (2026-08-22): the two form "steps" (plan choice, then
   deployment details) share one legend treatment -- promoted from
   --text-tertiary to --violet so each step reads as a real section
   header (matching .eyebrow's hierarchy language) rather than a quiet
   field hint. */
.tier-select-fieldset { border: 0; padding: 0; margin: 0 0 32px; }
.request-access-details-fieldset {
  border: 0;
  border-top: 1px solid var(--border-subtle);
  padding: 28px 0 0;
  margin: 0;
}
.tier-select-fieldset legend, .request-access-details-fieldset legend {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  margin: 0 0 14px;
  padding: 0;
}
.tier-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
@media (max-width: 760px) {
  .tier-cards { grid-template-columns: 1fr; }
}
.tier-card {
  display: block;
  position: relative;
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  background: var(--bg-void);
  padding: 16px 40px 14px 16px;
  cursor: pointer;
  touch-action: manipulation;
  transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.tier-card:hover { border-color: var(--violet-soft); }
/* Real, keyboard-reachable native radio -- visually tucked into the
   corner rather than hidden (display:none/visibility:hidden would drop
   it from the accessibility tree in some browsers and break keyboard
   focus entirely; this keeps it a real, focusable, screen-reader-visible
   control). Custom-drawn circle + checkmark (2026-08-22 redesign) so the
   selected plan carries a real icon indicator, not just the border/
   background color shift below -- a colorblind or low-vision visitor
   (and, independently, any screen-reader user via the input's own real
   `checked` state) still gets a non-color signal of which plan is
   selected. */
.tier-card input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  position: absolute;
  top: 14px;
  right: 14px;
  width: 22px;
  height: 22px;
  margin: 0;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  background-color: var(--bg-void);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}
.tier-card input[type="radio"]:hover { border-color: var(--violet-soft); }
.tier-card input[type="radio"]:checked {
  border-color: var(--violet-soft);
  background-color: var(--violet-button);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='5 13 10 18 19 7'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 13px 13px;
}
.tier-card input[type="radio"]:focus-visible { outline: 2px solid var(--violet-soft); outline-offset: 2px; }
.tier-card:has(input:checked), .tier-card.tier-card-selected { border-color: var(--violet-soft); background: var(--btn-login-tint); }
.tier-card-name { display: block; font-weight: 700; font-size: 1.02rem; margin-bottom: 2px; }
.tier-card-cameras { display: block; font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 10px; }
.tier-card-features { list-style: none; margin: 0; padding: 0; font-size: 0.82rem; color: var(--text-secondary); }
.tier-card-features li { padding: 3px 0; }
.tier-card-features li::before { content: "\2713"; color: var(--violet-soft); margin-right: 7px; }
.tier-card-coming-soon { color: var(--text-tertiary); font-style: italic; }

/* ---------- Form validation ---------- */
.field-required { color: var(--violet-soft); margin-left: 3px; }
.field-error {
  display: block;
  min-height: 1.1em;
  margin: -16px 0 14px;
  font-size: 0.76rem;
  color: var(--red);
}
input.invalid, textarea.invalid { border-color: var(--red); }

button.btn-submit:disabled {
  opacity: 0.6;
  cursor: not-allowed;
  box-shadow: none;
  transform: none;
}

/* ---------- Loading spinner ---------- */
.spinner {
  display: inline-block;
  width: 20px;
  height: 20px;
  border: 2px solid rgba(63, 169, 160, 0.25);
  border-top-color: var(--violet);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.video-loading {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  background: #000;
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  transition: opacity 0.25s ease;
}
.video-loading.loaded { opacity: 0; pointer-events: none; }

/* ---------- Login page ---------- */
/* Minimal, centered, deliberate — a security product's login, not an
   afterthought. Same dot-grid texture as the homepage hero, much more
   subdued, plus an explicit access-control tag above the form. */
.login-page {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  background-image: radial-gradient(circle, var(--border-subtle) 1px, transparent 1px);
  background-size: 26px 26px;
}
.login-box { width: 100%; max-width: 380px; }
.login-brand { justify-content: center; margin-bottom: 18px; }
.login-brand .brand-primary { font-size: 1.35rem; }
.login-brand .brand-secondary { font-size: 0.72rem; padding-top: 3px; }
.login-access-tag {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  margin-bottom: 28px;
}
.response-promise {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-secondary);
  margin-bottom: 24px;
}
.login-card { border-top: 2px solid var(--violet-soft); }

/* ---------- Login redesign (2026-08-21): Sign In / Sign Up simplicity ---------- */
/* Visual redesign pass (2026-08-21, founder-provided reference image):
   wordmark moves to the page's own top-left corner (a page-level mark,
   not the centered-above-card treatment every other auth page in this
   family still uses -- deliberately a NEW class, not a change to the
   shared .login-brand/.login-access-tag rules above, which activate.html/
   step_up.html/totp_verify.html/totp_setup.html/recovery_codes.html/
   oauth_link_error.html/oauth_not_configured.html/admin_step_up.html all
   still rely on unchanged). */
.login-page { position: relative; }
.login-page-brand {
  position: absolute;
  top: 28px;
  left: 32px;
}
.login-page-brand .brand-primary { font-size: 1.15rem; }
.login-page-brand .brand-secondary { font-size: 0.66rem; padding-top: 2px; }
@media (max-width: 480px) {
  .login-page-brand { top: 18px; left: 18px; }
  /* On a narrow viewport the card's content is nearly as tall as the
     whole viewport, so simple flex-centering left almost no top margin
     -- not enough to clear the absolutely-positioned brand mark above
     (found via an actual 375px Playwright screenshot, not assumed).
     Top-align with fixed clearance instead of vertically centering. */
  .login-page { align-items: flex-start; padding-top: 62px; }
}
/* Teal eyebrow line (matches the reference's "FULLY ENCRYPTED SIGN IN"
   treatment) -- login.html-only, a new class rather than a re-theme of
   the shared .login-access-tag (--text-tertiary, with a live-dot) every
   other auth-adjacent page above still uses unchanged. */
.login-eyebrow-tag {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  margin-bottom: 20px;
}
.login-eyebrow-icon { width: 14px; height: 14px; flex-shrink: 0; }
.login-card-icon { display: flex; justify-content: center; margin-bottom: 16px; color: var(--violet); }
.login-card-icon svg { width: 38px; height: 38px; }
.login-heading { margin: 0 0 8px; font-size: 1.5rem; text-align: center; }
.login-subtext { margin: 0 0 26px; font-size: 0.88rem; line-height: 1.5; text-align: center; color: var(--text-secondary); }
.login-password-label-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.login-forgot-link { font-size: 0.82rem; color: var(--text-tertiary); text-decoration: none; }
.login-forgot-link:hover, .login-forgot-link:focus-visible { color: var(--text-secondary); text-decoration: underline; }
/* Shared icon-prefixed input treatment (username field + password field
   both use it); .login-password-field stays a modifier on top of it,
   adding only the extra right-hand padding the Show/Hide toggle needs. */
.login-input-wrap { position: relative; display: flex; align-items: stretch; }
.login-input-wrap input { flex: 1; padding-left: 40px; }
.login-input-icon {
  position: absolute;
  left: 13px;
  top: 50%;
  transform: translateY(-50%);
  width: 18px;
  height: 18px;
  color: var(--text-tertiary);
  pointer-events: none;
}
.login-password-field input { padding-right: 64px; }
.login-password-toggle {
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: 0;
  color: var(--text-tertiary);
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  padding: 6px 10px;
}
.login-password-toggle:hover, .login-password-toggle:focus-visible { color: var(--text-primary); }
/* "Remember me on this device" (2026-08-22): visually noticeable
   (own row, own hint line right beneath it) but deliberately NOT
   alarm-colored -- this is an informational disclaimer about ordinary
   product behavior, not an error/warning state, so it reuses the same
   --text-tertiary treatment .field-hint already uses elsewhere on this
   page, not .alert-banner's warning/error palette. */
.login-remember-me-row { margin: 4px 0 22px; }
.login-remember-me-label {
  display: flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-body);
  font-size: 0.88rem;
  font-weight: 500;
  letter-spacing: normal;
  color: var(--text-primary);
  text-transform: none;
  cursor: pointer;
}
.login-remember-me-label input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--violet); cursor: pointer; }
.login-remember-me-hint { margin: 6px 0 0 24px; font-size: 0.78rem; line-height: 1.45; }
.login-signup-row { text-align: center; font-size: 0.9rem; color: var(--text-secondary); margin: 18px 0 0; }
.login-signup-row a { color: var(--violet-soft); font-weight: 600; text-decoration: none; }
.login-signup-row a:hover, .login-signup-row a:focus-visible { text-decoration: underline; }
.login-oauth-divider {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: 22px 0 14px;
  font-size: 0.72rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  /* was --text-tertiary (#777e86 on this card's darker background =
     2.47:1, well under WCAG AA's 4.5:1 -- found via an automated
     axe-core scan). --text-secondary passes comfortably. */
  color: var(--text-secondary);
}
.login-oauth-divider::before, .login-oauth-divider::after { content: ""; flex: 1; height: 1px; background: var(--border-subtle); }
.login-oauth-row { display: flex; gap: 12px; }
.login-oauth-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 10px; text-align: center; }
/* The real Google "G" logomark (Google's own multi-color brand asset,
   not a stroke icon) -- every other icon on this page follows the
   site's shared stroke-icon convention, but a brand mark isn't
   restyleable the way a generic UI glyph is. */
.login-oauth-icon { width: 18px; height: 18px; flex-shrink: 0; display: block; }
.login-oauth-btn-disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
@media (max-width: 480px) {
  .login-oauth-row { flex-direction: column; }
}
.login-back-row { text-align: center; margin-top: 18px; }

/* ---------- Dashboard app shell — operator console ---------- */
/* Deliberately denser and more utilitarian than the public site: tighter
   padding, three-tier color used as a running status language rather
   than isolated badges.

   TYPOGRAPHY (Satoshi migration, 2026-08-19): previously this class
   redefined --font-* to IBM Plex Sans/Mono, scoped to the dashboard
   only, because the global :root tokens were still Sora/Work Sans/Fira
   Code. Now that :root itself is Satoshi + IBM Plex Mono site-wide (see
   :root's own comment above), that per-class redefinition would just
   restate the same values root already provides -- removed as dead
   weight, not functionally changed. Every dashboard rule reading
   var(--font-body)/var(--font-mono)/var(--font-display) still resolves
   the same way, now via the ordinary cascade instead of a local
   override.

   What stays true regardless of the redefinition's removal: several
   dashboard rules use --font-mono decoratively -- nav items, stat/
   table-column labels, status text, buttons, form controls, modal
   chrome, person names -- none of which are literal machine data. Per
   the typography brief, those were moved to --font-body explicitly, so
   Plex Mono is reserved for what the brief actually means by "technical
   values": timestamps, camera/feed IDs, confidence scores, the System
   tab's real config readout, and literal identifiers inside <code>
   (CAMERA_SOURCE, config.py). Mono is a legibility choice for genuinely
   tabular/machine-originated values only, not "the HUD look applied
   broadly." */
.dash-body {
  font-family: var(--font-body);
  background: var(--bg-void);
  margin: 0;
}

/* ---------- Sidebar shell (replaces the old top header + tab bar) ----------
   Persistent left nav is an app/dashboard-software convention (Datadog,
   Splunk, Palantir Foundry) — the public marketing pages intentionally
   keep a top nav instead (see _partials), since there's no persistent
   "current section" concept there. */
.dash-shell { display: flex; min-height: 100vh; }
.dash-sidebar {
  width: 216px;
  flex-shrink: 0;
  background: #0c0e11;
  border-right: 1px solid var(--border-subtle);
  display: flex;
  flex-direction: column;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow-y: auto;
}
.dash-sidebar-brand {
  padding: 16px 18px;
  border-bottom: 1px solid var(--border-subtle);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.dash-sidebar-brand .dash-brand .brand-primary { font-size: 1rem; }
.dash-sidebar-brand .dash-brand .brand-secondary { font-size: 0.54rem; }
.dash-subtitle { color: var(--text-tertiary); font-size: 0.72rem; font-family: var(--font-body); letter-spacing: 0.02em; }

.dash-sidenav { display: flex; flex-direction: column; padding: 10px 10px 0; gap: 1px; }
.dash-navitem {
  appearance: none;
  border: none;
  background: none;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  padding: 8px 10px;
  border-radius: 5px;
  color: var(--text-secondary);
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 0.85rem;
  cursor: pointer;
  text-decoration: none;
  box-sizing: border-box;
  transition: background 0.12s ease, color 0.12s ease;
}
.dash-navicon { width: 16px; height: 16px; flex-shrink: 0; color: var(--text-tertiary); transition: color 0.12s ease; }
.dash-navitem:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
.dash-navitem:hover .dash-navicon { color: var(--text-secondary); }
.dash-navitem.active { background: rgba(76, 111, 165, 0.16); color: var(--violet-soft); }
.dash-navitem.active .dash-navicon { color: var(--violet-soft); }

/* Focus-visible states for the dashboard's interactive controls -- none
   of these had a visible focus ring before this pass (only form inputs
   did, via the generic input:focus rule above in the Forms section).
   Same solid-ring pattern as that existing rule, not a new visual
   language. Scoped to dashboard-specific selectors rather than a
   sitewide `button:focus-visible` rule, since the public marketing
   pages weren't part of this pass yet -- closed separately below. */
.dash-navitem:focus-visible,
.dash-sidebar-logout:focus-visible,
.camera-toolbar-add:focus-visible,
.admin-feed-save:focus-visible,
.admin-delete-btn:focus-visible,
.enroll-checkbox:focus-visible,
.enroll-modal-header button:focus-visible,
.enroll-modal-actions button:focus-visible,
.data-table tbody tr:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
  border-radius: 5px;
}

/* Same focus-visible treatment for the public marketing site's
   interactive controls (nav links, brand mark, both button styles, the
   form submit button, footer nav links, the sticky mobile CTA bar, and
   the cookie-banner link/button) -- previously only the native browser
   outline showed here (never suppressed, just unstyled/inconsistent
   with the dark theme; see accessibility audit finding).
   Rebuilt (UI polish pass, 2026-08-21) as a real `outline` instead of a
   box-shadow standing in for one -- see the --focus-ring token comment
   above for why. Public-site scope only; the dashboard's identical-
   looking block above this one is untouched (still box-shadow-based,
   out of scope for this pass). */
.nav-links a:focus-visible,
.brand-mark:focus-visible,
.btn-login:focus-visible,
.btn-primary:focus-visible,
.btn-secondary:focus-visible,
button.btn-submit:focus-visible,
.footer-nav a:focus-visible,
.footer-legal a:focus-visible,
.footer-social-link:focus-visible,
.mobile-cta-link:focus-visible,
.cookie-banner-text a:focus-visible,
.hp-hero-evidence-caption a:focus-visible,
.inline-cta-link:focus-visible {
  outline: 2px solid var(--violet-soft);
  outline-offset: 1px;
  border-radius: 5px;
}

.dash-sidenav-section { padding: 16px 10px 0; display: flex; flex-direction: column; gap: 1px; }
.dash-sidenav-heading {
  display: block;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.6rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  padding: 0 10px 6px;
}

.dash-sidebar-user {
  /* Sits directly under the nav (not pinned to the viewport bottom via
     margin-top: auto) — the sidebar's own height: 100vh already gives it
     full-height chrome, but stretching the CONTENT to match left a large
     dead gap between the nav and this block on any page with few nav
     items. Any true leftover space now falls below this block instead,
     which reads as normal sidebar footer padding rather than a gap
     sitting between two functionally different groups. */
  margin-top: 24px;
  padding: 14px 18px;
  border-top: 1px solid var(--border-subtle);
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-family: var(--font-mono);
  font-size: 0.76rem;
}
.dash-sidebar-username { color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-sidebar-logout { color: var(--red); text-decoration: none; font-weight: 600; flex-shrink: 0; margin-left: 10px; }
.dash-sidebar-logout:hover { text-decoration: underline; }

.dash-content { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* .admin-feed-save is shared with admin.html (Rename/Save buttons there
   too) -- scoped here to ONLY change how it renders inside the
   dashboard (buttons are Plex Sans per the typography brief), without
   touching the base rule admin.html also depends on, which is out of
   scope for this pass. */
.dash-body .admin-feed-save { font-family: var(--font-body); font-weight: 600; }

/* MOBILE NAV DRAWER (UX fix, 2026-08-22) — replaces this rule's previous
   narrow-viewport treatment (a horizontally-scrolling single-row strip
   the sidebar squeezed itself into below 780px). That was functional in
   the narrowest technical sense but a real usability regression in
   practice: the brand row, the nav items, and (on admin.html) the admin
   links row all competed for one cramped horizontal scroller, longer
   labels ("Recording health"-length text would have) had nowhere good to
   go, and it matched no mobile nav pattern a phone user would recognize.
   Below is a standard off-canvas drawer instead: dashboard.html/
   admin.html now both render a small fixed-markup mobile top bar
   ([☰][logo][profile], see each template's own <header
   class="dash-mobile-topbar">) ahead of .dash-shell, and the SAME
   .dash-sidebar element used on desktop becomes the drawer's content —
   not a second, duplicated nav — toggled open/closed by each template's
   own <script> ("Mobile navigation drawer"). Same breakpoint as before
   (780px), same "layout-only, is_admin-gated blocks are untouched"
   guarantee this rule always had — nothing here changes WHAT is shown to
   which role, only how it's reached below that width. Reuses this file's
   own --z-sticky/--z-fixed/--z-overlay scale and --focus-ring/
   --elevation-modal tokens throughout; no new design tokens introduced. */
.dash-mobile-topbar,
.dash-mobile-backdrop,
.dash-drawer-close {
  display: none;
}
@media (max-width: 780px) {
  .dash-mobile-topbar {
    display: flex;
    align-items: center;
    gap: 8px;
    position: sticky;
    top: 0;
    z-index: var(--z-fixed);
    height: 56px;
    padding: 0 10px;
    background: #0c0e11;
    border-bottom: 1px solid var(--border-subtle);
  }
  .dash-mobile-menu-toggle {
    appearance: none;
    border: none;
    background: none;
    color: var(--text-secondary);
    width: 44px;
    height: 44px;
    flex-shrink: 0;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    border-radius: var(--radius-sm, 5px);
  }
  .dash-mobile-menu-toggle:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
  .dash-mobile-menu-toggle:focus-visible { outline: none; box-shadow: var(--focus-ring); }
  .dash-mobile-menu-toggle svg { width: 22px; height: 22px; }
  .dash-mobile-brand {
    display: flex;
    flex-direction: column;
    line-height: 1.15;
    text-decoration: none;
    min-width: 0;
    overflow: hidden;
  }
  .dash-mobile-brand .brand-primary { font-size: 0.86rem; }
  .dash-mobile-brand .brand-secondary { font-size: 0.46rem; }
  .dash-mobile-brand:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm, 5px); }
  .dash-mobile-profile {
    margin-left: auto;
    flex-shrink: 0;
    width: 30px;
    height: 30px;
    border-radius: 999px;
    background: var(--violet-glow, rgba(63, 169, 160, 0.22));
    color: var(--violet-soft, #6fb8b0);
    font-family: var(--font-mono);
    font-size: 0.8rem;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
  }

  .dash-mobile-backdrop {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(5, 6, 8, 0.72);
    z-index: var(--z-sticky);
  }
  .dash-mobile-backdrop.hidden { display: none; }

  /* Background-scroll lock while the drawer is open — toggled by the
     same script that opens/closes the drawer, matching the earlier
     ".hidden" utility's own !important-free simplicity. */
  body.dash-drawer-locked { overflow: hidden; }

  .dash-sidebar {
    position: fixed;
    /* Starts below the mobile top bar (56px), not at the very top of the
       viewport — so the drawer never covers the [☰] button it's opened
       from, keeping it reachable as an explicit close affordance the
       whole time the drawer is open, not just via Escape/backdrop. */
    top: 56px;
    left: 0;
    height: calc(100vh - 56px);
    width: min(84vw, 300px);
    max-width: 300px;
    z-index: var(--z-overlay);
    border-right: 1px solid var(--border-subtle);
    border-bottom: none;
    transform: translateX(-100%);
    /* visibility (not just the transform) removes every descendant from
       the tab order while closed — without this, a keyboard user tabbing
       past the mobile top bar would tab straight into the off-canvas
       drawer's own links next, before ever reaching the page's real
       content. Delayed via transition so it only snaps to hidden once
       the slide-out animation has actually finished (and snaps back to
       visible immediately on open, no delay). */
    visibility: hidden;
    transition: transform 0.2s ease, visibility 0s linear 0.2s;
    box-shadow: var(--elevation-modal);
  }
  .dash-sidebar.open {
    transform: translateX(0);
    visibility: visible;
    transition: transform 0.2s ease, visibility 0s linear 0s;
  }
  .dash-drawer-close {
    display: flex;
    align-items: center;
    justify-content: center;
    align-self: flex-end;
    appearance: none;
    border: none;
    background: none;
    color: var(--text-secondary);
    font-size: 1.4rem;
    line-height: 1;
    width: 44px;
    height: 44px;
    margin: 4px 4px 0 auto;
    cursor: pointer;
    border-radius: var(--radius-sm, 5px);
  }
  .dash-drawer-close:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
  .dash-drawer-close:focus-visible { outline: none; box-shadow: var(--focus-ring); }

  .dash-sidebar-brand { flex-direction: row; align-items: center; justify-content: space-between; padding: 12px 14px; }
  .dash-sidenav, .dash-sidenav-section {
    flex-direction: column;
    flex-wrap: nowrap;
    overflow-x: visible;
    padding: 8px 10px;
    gap: 2px;
  }
  /* Section headings ("Administration", "Jump to section") stay visible
     in the drawer, unlike the old horizontal-strip treatment that hid
     them for space — a vertical off-canvas panel has room, and the
     grouping is genuinely useful context here. */
  /* Touch targets: 44px is the WCAG 2.5.5/2.5.8-aligned minimum this
     project has already fixed elsewhere this session — nav rows were
     previously ~34px tall (8px padding + a 16px icon) at this width. */
  .dash-navitem { flex-shrink: 0; min-height: 44px; }
  .dash-sidebar-user { margin-top: 0; min-height: 52px; }
}

/* ---------- Loading skeletons (shape-only placeholders) ----------
   Used ONLY where content is genuinely asynchronous and briefly absent
   on first paint: the camera tile's first MJPEG frame, the Alert Log's
   first /dashboard/alerts fetch, and the Roster's first /dashboard/roster
   fetch. Every one of these is removed by the same JS that already
   replaces the real content (see dashboard.html) the instant that
   content, its genuine empty state, or a load failure is known — never
   left showing after any of those three outcomes. A single flat
   surface-color block with a slow opacity pulse, not a moving shimmer
   sweep — deliberately calm, matching the console's low-motion,
   low-glare aesthetic rather than reading as decorative. The page-wide
   prefers-reduced-motion rule near the top of this file already forces
   every animation's duration to ~0 for these too, so no extra media
   query is needed here. */
@keyframes skeleton-pulse {
  0%, 100% { opacity: 0.5; }
  50% { opacity: 1; }
}
.skeleton-block {
  background: var(--bg-surface-raised);
  border-radius: 4px;
  animation: skeleton-pulse 1.6s ease-in-out infinite;
}

/* Real-time stats strip — six cells, every value client-computed from
   /dashboard/stats (real uptime/counters/rate), never decorative. Tier
   cells get a colored top edge so the tier language starts here, not
   just in the log below. */
.dash-statbar {
  display: flex;
  flex-wrap: wrap;
  border-bottom: 1px solid var(--border-subtle);
  background: var(--bg-void);
}
.dash-stat {
  flex: 1 1 140px;
  padding: 9px 16px;
  border-right: 1px solid var(--border-subtle);
  border-top: 2px solid transparent;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.dash-stat:last-child { border-right: none; }
.dash-stat.tier-authorized { border-top-color: var(--tier-authorized); }
.dash-stat.tier-borderline { border-top-color: var(--tier-borderline); }
.dash-stat.tier-unknown { border-top-color: var(--tier-unknown); }
.dash-stat-value {
  font-family: var(--font-mono);
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.dash-stat.tier-authorized .dash-stat-value { color: var(--tier-authorized); }
.dash-stat.tier-borderline .dash-stat-value { color: var(--tier-borderline); }
.dash-stat.tier-unknown .dash-stat-value { color: var(--tier-unknown); }
.dash-stat-label {
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.65rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.dash-main {
  padding: 16px;
  background: var(--bg-void);
  min-height: 60vh;
  flex: 1;
}
.dash-panel.hidden { display: none; }

/* Multi-camera-ready: a grid, not a single hardcoded frame — adding a
   second/third source later is a data change (another tile renders),
   not a template rewrite. Only one real tile exists today; the second
   slot below is an explicit placeholder, not a mystery empty box. */
.camera-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
  gap: 14px;
  max-width: 1400px;
  margin: 0 auto;
}
/* UX-audit fix: minmax(420px, ...) is correct on desktop (never squishes
   a tile below a usable size) but forces a track wider than the viewport
   itself on any phone under ~420px, which was overflowing the whole page
   horizontally. Below that width there's no multi-column layout to
   preserve anyway, so the tile just takes the full (scroll-free) width. */
@media (max-width: 460px) {
  .camera-grid { grid-template-columns: 1fr; }
}
.camera-tile {
  border: 1px solid #262b31;
  border-radius: 6px;
  background: #111417;
  overflow: hidden;
}
.camera-tile-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border-bottom: 1px solid #262b31;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.78rem;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--text-secondary);
}
/* CAMERA FLEET STATUS (P6-C, 2026-08-22) — a small, muted subtitle line
   under the tile header showing last-frame-seen timing (see
   dashboard.html's camera_diagnostics JS). Reuses the same muted-gray
   tone .status already uses, not a new color token, since this is the
   same "quiet secondary signal" as the status badge, just supplementary
   detail rather than the primary state. */
.camera-tile-lastframe {
  padding: 4px 12px;
  font-family: var(--font-mono, monospace);
  font-size: 0.7rem;
  color: #8a8f98;
  border-bottom: 1px solid #262b31;
}
.stream-wrap {
  position: relative;
  background: #000;
  width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
}
/* DESIGN-HOOK NOTE (investigated 2026-08-18, Phase 1 review item 3):
   this rule -- and .roster-photo/.roster-photo-placeholder further down
   -- style <img> elements the real dashboard.html ships with NO src
   attribute in the initial HTML by design: dashboard.html's own script
   assigns .stream-img's src only after window "load" (so a slow/absent
   camera can't hold up page load, since a multipart MJPEG request that
   never sends a first frame never fires <img> load/error either), and
   .roster-photo's src is assigned per-person by loadRoster()'s fetch
   response, not known at template-render time. This is confirmed,
   intentional, load-order-critical behavior, not an oversight.
   The project's own design-hook tooling (.claude/skills/impeccable)
   flags these as "broken-image" on every edit to this file. Two
   suppression attempts this session (a file-scoped exact-value ignore,
   then a file-scoped wildcard "*" ignore, both via hook-admin.mjs
   ignore-value) were added to .impeccable/config.json but empirically
   confirmed NOT to suppress the finding on a real subsequent edit --
   traced through hook-lib.mjs's isIgnoredFindingValue()/
   findingMatchesScopedIgnoreFile() and the matching logic reads as if
   it should work; the actual mismatch wasn't isolated within the time
   this investigation was scoped to. No further suppression attempt was
   made per instruction not to keep fighting it. NOT fixed by weakening
   the rule, disabling it, or adding a fake placeholder src just to
   quiet the tool -- this comment IS the documentation of the accepted,
   understood, sanctioned pattern. Safe to keep ignoring this specific
   finding on this specific file on sight. */
/* Absolute + inset:0, not flex-centered width/height:100% — a percentage
   height on a flex-child <img> depends on the flex algorithm resolving a
   definite cross-size for a replaced element, which is inconsistent
   across browsers/versions and is exactly what was silently failing here
   (the image rendered at its native frame size, centered by the old
   align-items/justify-content instead of filling the box). Absolute
   positioning against the relatively-positioned .stream-wrap sizes this
   deterministically off the parent's box, the same pattern .stream-empty
   already uses successfully directly below. */
.stream-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  max-width: none;
}
/* Shown only until this specific tile's very first real outcome is known
   (a frame actually arrives, the 6s no-source timeout fires, or the
   <img> itself errors) — dashboard.html hides it at exactly those three
   points and never shows it again, so it can't sit on top of a live
   frame, contradict the "No source"/"Stream disconnected" states, or
   get stuck if the connection attempt fails. z-index 1 keeps it above
   the empty (no-src) <img> box but below the live badge (2), which is
   moot in practice since the badge only shows once this is hidden. */
.stream-skeleton {
  position: absolute;
  inset: 0;
  z-index: 1;
}
/* Design-audit fix: a genuinely dark frame (low-light room) rendered as
   plain black, indistinguishable from a broken/disconnected stream. This
   sits on top of .stream-img itself (not blended into .stream-wrap's own
   background, which an opaque frame — bright or dark — fully covers
   either way) so it stays visible regardless of how dark the real video
   content is. Only ever shown while dashboard.html's setStreamStatus()
   has genuinely confirmed a frame arrived — see that function. */
.stream-live-badge {
  position: absolute;
  left: 10px;
  bottom: 10px;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 9px;
  border-radius: 999px;
  background: rgba(8, 9, 11, 0.6);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.68rem;
  letter-spacing: 0.04em;
  color: var(--text-secondary);
  pointer-events: none;
}
.stream-live-badge .pulse-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--tier-authorized);
  box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
  animation: pulse-glow 2.2s ease-out infinite;
}
.stream-empty {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 32px;
  background: #000;
  color: var(--text-tertiary);
  font-family: var(--font-body);
  font-size: 0.85rem;
  line-height: 1.6;
}
.stream-empty code {
  /* Literal config identifiers (CAMERA_SOURCE, config.py) stay mono even
     though the surrounding sentence around them is now sans -- this is
     exactly the "technical values where monospace materially improves
     scanning" case, not decoration. */
  font-family: var(--font-mono);
  color: var(--text-secondary);
  background: var(--bg-surface-raised);
  padding: 1px 5px;
  border-radius: 3px;
}
/* .status/.status .dot/.status.live/.status.down/@keyframes pulse-glow-green
   removed (Slice 1, NEO_DESIGN_AUDIT.md migration): dashboard.html's own
   2026-08-22 comment confirms it already migrated this exact treatment
   onto the shared .status-badge/.status-badge-dot component; grepped the
   whole templates/ tree first and confirmed zero remaining references to
   this bare .status/.dot markup or to pulse-glow-green before removing.
   pulse-glow-red (used by .live-dot.alert, unrelated) is untouched. */

/* ---------- Alert log ---------- */
.alert-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  max-width: 1400px;
  margin: 0 auto 12px;
}
.alert-filters input, .alert-filters select {
  background: #111417;
  border: 1px solid #262b31;
  border-radius: 5px;
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: 0.85rem;
  padding: 7px 10px;
}
/* The sitewide form rule (Forms section, `input[type="text"], ...,
   select, textarea { width: 100%; ... }`) forces this bar's <select>
   to fill its entire row on its own -- .alert-filters select at
   (0,1,1) specificity already wins the cascade over the generic
   `select` at (0,0,1) without touching that sitewide rule, so this is
   scoped entirely to this filter bar. Each control gets an intentional
   flex-basis instead of the generic width:100%, so the bar uses
   available horizontal space instead of stacking one-per-row on every
   viewport that has room for more than one. */
.alert-filters input[type="search"] { flex: 1 1 200px; }
.alert-filters select { flex: 0 1 160px; width: auto; }
.alert-filters input[type="date"] { flex: 0 1 170px; width: auto; }

/* Mobile: deliberate full-width stack, not just "whatever wrapping
   happens to produce" -- a 160px select or a native date input sitting
   alone next to leftover space at 390px reads as broken, not
   intentional. Matches this file's documented mobile breakpoint
   (see the :root breakpoint-reference comment). */
@media (max-width: 480px) {
  .alert-filters input, .alert-filters select, .alert-filters button {
    flex: 1 1 100%;
    width: 100%;
  }
}
.alert-filters button {
  background: none;
  border: 1px solid #262b31;
  border-radius: 5px;
  color: var(--text-tertiary);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.8rem;
  padding: 7px 12px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.alert-filters button:hover { color: var(--text-primary); border-color: var(--text-tertiary); }

.log-panel {
  width: 100%;
  max-width: 1400px;
  margin: 0 auto;
  border: 1px solid #262b31;
  border-radius: 6px;
  background: #111417;
}
.log-header {
  padding: 10px 14px;
  border-bottom: 1px solid #262b31;
  font-size: 0.76rem;
  font-weight: 600;
  color: #a9adb4;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
#log-count { font-family: var(--font-body); font-weight: 500; color: var(--text-tertiary); text-transform: none; letter-spacing: normal; }

/* ---------- Shared data-table component (2026-08-18) ----------
   One base pattern for anything that's genuinely tabular data --
   currently the Alert Log and the Roster. "Tables should remain
   tables": real <table> markup, not styled <div> rows -- a screen
   reader gets real row/column semantics, and a sighted operator gets a
   real header row to scan against, neither of which the old div-based
   .log-entry/.roster-card markup provided. */
/* overflow-x here (not just -y) so a narrow viewport gets a real
   horizontal scrollbar on the table instead of silently clipping the
   rightmost column (found during the responsive check at 390px --
   the Alert Log's 5-column table has no room to compress further once
   its thumb/checkbox columns are at their functional minimum). */
.table-scroll { max-height: 520px; overflow-y: auto; overflow-x: auto; }
.data-table { min-width: 480px; }
.data-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.data-table thead th {
  position: sticky;
  top: 0;
  background: #111417;
  text-align: left;
  padding: 8px 14px;
  font-family: var(--font-body);
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  border-bottom: 1px solid #262b31;
}
.data-table tbody tr {
  border-bottom: 1px solid #1a1d21;
  border-left: 3px solid transparent;
}
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody td { padding: 5px 14px; vertical-align: middle; }

/* Alert Log -- tier color is the row's own left border (the one place
   this pass keeps a color-as-status-signal pattern, since it's exactly
   what --tier-* exists for), plus the identity text itself. */
.alert-table tbody tr.unknown { border-left-color: var(--tier-unknown); }
.alert-table tbody tr.borderline { border-left-color: var(--tier-borderline); }
.alert-table tbody tr.authorized { border-left-color: var(--tier-authorized); }
.alert-table .col-check { width: 32px; }
.alert-table .col-thumb { width: 42px; }
.log-thumb {
  width: 26px; height: 26px;
  border-radius: 4px;
  object-fit: cover;
  flex-shrink: 0;
  background: #1a1d21;
  display: block;
}
.log-thumb-dot {
  width: 26px; height: 26px;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #1a1d21;
}
.log-thumb-dot::before {
  content: "";
  width: 9px; height: 9px;
  border-radius: 50%;
  background: var(--tier-authorized);
}
/* A person's name is identity content, not a machine value -- Plex Sans,
   not Plex Mono (confidence score/feed tag/timestamp right next to it
   stay mono deliberately -- those ARE literal detection-model output). */
.alert-table .name { font-weight: 600; font-family: var(--font-body); }
.alert-table tr.unknown .name { color: var(--tier-unknown); }
.alert-table tr.borderline .name { color: var(--tier-borderline); }
.alert-table tr.authorized .name { color: var(--tier-authorized); }
/* Color-contrast fix (found by re-enabling dashboard-a11y.spec.js, which
   had been silently non-runnable due to an unrelated GPU-import crash in
   its own test fixture -- see dashboard_test_server.py's docstring --
   and so had not actually caught this real, pre-existing defect until
   now, 2026-08-22): the old hardcoded #6b7078 (not a design token) only
   computes to 3.71:1 against .log-panel's own hardcoded #111417
   background -- fails WCAG 2 AA's 4.5:1 minimum. --text-tertiary
   (#798088) is the closest existing token in this codebase's own
   hierarchy and computes to a real 4.63:1 against this same background
   (independently verified via the same WCAG relative-luminance formula
   axe-core uses, not just eyeballed) -- also brings this rule onto the
   token system like everything else in this file, instead of a one-off
   hex value. */
.alert-table .conf { color: var(--text-tertiary); font-weight: 400; margin-left: 6px; font-family: var(--font-mono); }
.log-feed-tag {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.04em;
  color: var(--text-tertiary);
  border: 1px solid var(--border-strong);
  border-radius: 4px;
  padding: 1px 6px;
  white-space: nowrap;
}
/* Same color-contrast fix as .alert-table .conf directly above -- see
   that rule's comment for the full reasoning. */
.alert-table .col-time { color: var(--text-tertiary); white-space: nowrap; font-variant-numeric: tabular-nums; font-size: 0.78rem; font-family: var(--font-mono); }
.enroll-checkbox { flex-shrink: 0; width: 15px; height: 15px; cursor: pointer; accent-color: var(--violet); }

/* ---------- Enroll-from-alert modal (admin only) ---------- */
.enroll-modal {
  position: fixed;
  inset: 0;
  background: rgba(5, 6, 8, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
}
.enroll-modal.hidden { display: none; }
.enroll-modal-box {
  width: min(420px, calc(100vw - 32px));
  background: var(--bg-surface-raised, #14171b);
  border: 1px solid var(--border-strong);
  border-radius: 8px;
  padding: 18px;
  /* The one dashboard surface that's genuinely elevated above the rest
     of the page (floating over a dimmed backdrop, not flush in normal
     page flow like every other panel) -- see --elevation-modal's own
     definition for why this is scoped to just this one component. */
  box-shadow: var(--elevation-modal);
}
.enroll-modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-family: var(--font-body);
  font-size: 0.92rem;
  font-weight: 600;
  margin-bottom: 8px;
}
.enroll-modal-header button {
  background: none;
  border: none;
  color: var(--text-secondary);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
}
.enroll-modal-note { font-size: 0.78rem; color: var(--text-secondary); line-height: 1.5; margin-bottom: 12px; }
/* P7-C enrollment UX: frames the snapshot-based enroll dialog as a
   fallback path relative to capture_enrollment_photos.py's guided
   multi-pose capture -- text-tertiary + a left border (not the alarming
   red .enroll-modal-warning treatment; this is guidance, not an error). */
.enroll-modal-recommend {
  font-size: 0.76rem;
  /* --text-secondary, not --text-tertiary: this codebase's own axe-core
     scan already found --text-tertiary (#777e86-ish) falls just under
     WCAG AA's 4.5:1 minimum at small font sizes on a dark panel
     background (see .hp-hero-evidence-caption's identical fix above) --
     using the same already-verified-passing token here instead of
     reintroducing that exact contrast problem in a new element. */
  color: var(--text-secondary);
  line-height: 1.5;
  margin: 0 0 12px;
  padding: 8px 10px;
  border-left: 2px solid var(--violet-soft);
  background: rgba(63, 169, 160, 0.06);
}
.enroll-modal-recommend code { font-family: var(--font-mono); color: var(--text-secondary); }
.enroll-modal-variety-hint {
  font-size: 0.78rem;
  color: var(--tier-borderline);
  line-height: 1.5;
  margin: 0 0 12px;
}
.enroll-modal-variety-hint.hidden { display: none; }
.enroll-modal-thumbs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.enroll-modal-thumbs .log-thumb { width: 48px; height: 48px; }
.enroll-modal-label { display: block; font-size: 0.76rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; font-family: var(--font-body); }
/* Color-contrast fix (found by admin-a11y.spec.js, 2026-08-22): the
   site-wide generic `label { color: var(--text-tertiary); }` rule
   (#798088) computes to only 4.2:1 against this modal's own
   --bg-surface-raised background (#191e24) -- fails WCAG 2 AA's 4.5:1
   minimum for normal-weight text (independently verified: #798088 is
   fine, 4.88:1, against --bg-void/--bg-surface, which is presumably why
   this was never caught before -- it only fails specifically inside a
   raised-background modal). Real, bare <label> elements exist inside
   this component (e.g. templates/admin.html's edit-camera-modal form) --
   this is not the same class as .enroll-modal-label above (a styled <p>,
   already using --text-secondary). Scoped to .enroll-modal-box only
   (used exclusively by dashboard.html/admin.html/admin_content.html/
   admin_requests.html -- confirmed via grep, no public-facing page uses
   this component) rather than changing the global `label` rule's color
   or --text-tertiary's value, both of which have far wider blast radius
   across the whole site, including public pages outside this fix's
   scope. */
.enroll-modal-box label { color: var(--text-secondary); }
.enroll-modal-error { color: var(--tier-unknown); font-size: 0.78rem; margin-top: 8px; }
.enroll-modal-error.hidden { display: none; }
.enroll-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
/* Touch-target remediation (Neo UX final cleanup, 2026-09-04): measured
   at 31px across every consumer of this component (confirmed via
   browser geometry assertions, not assumed) -- below the 44px minimum.
   Scoped to `.enroll-modal-actions button` (any button inside this
   component's footer, regardless of which class it also carries --
   unstyled :first-child Cancel, .admin-feed-save, .admin-delete-confirm,
   .btn-secondary, .admin-delete-btn all appear here across admin.html's
   8 modals, dashboard.html's enroll modal, admin_content.html's confirm
   modal, and admin_requests.html's 2 modals) rather than any one of
   those individual classes, several of which (.btn-secondary especially)
   are also used far outside any modal and must not be resized globally.
   min-height only -- does not touch color, spacing between buttons,
   border-radius, or focus-visible treatment, all already correct. */
.enroll-modal-actions button {
  min-height: 44px;
}
.enroll-modal-actions button:first-child {
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.82rem;
  padding: 6px 14px;
  border-radius: 5px;
  cursor: pointer;
}

/* ---------- Roster panel ----------
   A real table (see the shared .data-table component above the Alert Log
   rules) instead of the previous wrapping card grid -- a roster is a
   list of identities to scan/compare, which reads faster as aligned
   rows/columns than as a variable-width card grid, and it's the same
   .data-table pattern the Alert Log already uses, not a second
   bespoke component. */
.roster-panel-shell {
  width: 100%;
  max-width: 1400px;
  margin: 0 auto;
  border: 1px solid #262b31;
  border-radius: 6px;
  background: #111417;
}
.roster-table .col-photo { width: 58px; }
.roster-photo, .roster-photo-placeholder {
  width: 40px; height: 40px;
  border-radius: 6px;
  object-fit: cover;
  flex-shrink: 0;
  background: #1a1d21;
  display: block;
}
/* Non-admin roster placeholder — /dashboard/roster-photo/{name} is
   admin-only (serves decrypted biometric photo bytes), so a non-admin
   gets this neutral glyph instead of an <img> request that would 403.
   Same box geometry as .roster-photo so rows don't jump between roles. */
.roster-photo-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-tertiary);
  border: 1px solid var(--border-subtle);
}
.roster-photo-placeholder svg { width: 20px; height: 20px; }
.roster-table .name { font-weight: 600; font-size: 0.88rem; color: var(--text-primary); }
.roster-table .col-status { font-family: var(--font-body); font-weight: 600; font-size: 0.72rem; color: var(--tier-authorized); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.roster-table .col-count { font-family: var(--font-body); font-size: 0.8rem; color: var(--text-tertiary); white-space: nowrap; }

/* Roster: first-load skeleton rows — shown only until this page load's
   very first /dashboard/roster response resolves (see dashboard.html's
   loadRoster(), which replaces the table body's entire contents on
   success, genuine-empty, AND failure — so this can never survive any
   of those three outcomes). Row geometry mirrors a real roster row
   (photo + name + meta) so real rows replace these without a layout
   jump. */
.roster-skeleton-photo { width: 40px; height: 40px; border-radius: 6px; }
.roster-skeleton-text { display: flex; flex-direction: column; gap: 6px; }
.roster-skeleton-name { height: 12px; width: 120px; }
.roster-skeleton-meta { height: 9px; width: 70px; }

/* ---------- System panel ---------- */
#system-panel .hud-value { font-family: var(--font-mono); }

/* ---------- Admin panel ---------- */
.admin-main { padding: 20px 16px 60px; }
.admin-grid {
  display: grid;
  grid-template-columns: minmax(280px, 420px) 1fr;
  gap: 16px;
  max-width: 1400px;
  margin: 0 auto;
  align-items: start;
}
@media (max-width: 900px) {
  .admin-grid { grid-template-columns: 1fr; }
}
.admin-panel-block { padding-bottom: 4px; }
.admin-form { padding: 16px 18px; }
.admin-form-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  margin: 0 0 8px;
}
.admin-checkbox-list { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; }
/* Chip-style, not a raw browser checkbox sitting next to plain text — the
   whole pill is clickable (label wraps the input) and its border/fill
   respond to :checked via :has(), so a selected feed reads as a toggled
   chip rather than a checkbox someone might miss at a glance. */
.admin-checkbox {
  display: flex;
  align-items: center;
  gap: 7px;
  font-family: var(--font-mono);
  font-size: 0.78rem;
  color: var(--text-secondary);
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
  padding: 6px 12px 6px 10px;
  border: 1px solid var(--border-strong);
  border-radius: 20px;
  background: var(--bg-void);
  cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.admin-checkbox:hover { border-color: var(--text-tertiary); }
.admin-checkbox:has(input:checked) {
  border-color: var(--violet-soft);
  color: var(--text-primary);
  background: rgba(63, 169, 160, 0.1);
}
.admin-checkbox input {
  width: 14px;
  height: 14px;
  margin: 0;
  accent-color: var(--violet);
  cursor: pointer;
}
.admin-form-empty { color: var(--text-tertiary); font-size: 0.82rem; font-family: var(--font-mono); }
.admin-panel-footnote {
  margin: 0 18px 16px;
  padding-top: 12px;
  border-top: 1px solid #1a1d21;
  font-size: 0.78rem;
  color: var(--text-tertiary);
  line-height: 1.6;
}
.admin-panel-footnote a { color: var(--violet); }
.admin-panel-footnote code {
  font-family: var(--font-mono);
  font-size: 0.85em;
  color: var(--text-secondary);
  background: var(--bg-surface-raised);
  padding: 1px 5px;
  border-radius: 3px;
}

.admin-users-table { padding: 0 18px 18px; }
.admin-table-headers {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 0 8px;
  border-bottom: 1px solid var(--border-strong);
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}
.admin-table-headers span:first-child { min-width: 160px; }
.admin-user-row {
  padding: 10px 0;
  margin: 0 -10px;
  padding-left: 10px;
  padding-right: 10px;
  border-radius: 6px;
  border-bottom: 1px solid #1a1d21;
  display: flex;
  flex-direction: column;
  gap: 10px;
  transition: background 0.15s ease;
}
.admin-user-row:hover { background: var(--bg-surface-raised, #14171b); }
.admin-user-row:last-child { border-bottom: none; }
.admin-user-row-top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  width: 100%;
}
/* Two clearly separated permission sections per user — Camera Access
   (which feeds an account can view) and Authorized People (which
   enrolled identities an account is authorized for) are deliberately
   kept visually and structurally distinct so they're never confused for
   the same permission (see auth.py's module docstring / dashboard_routes
   for why they're independent data). */
.admin-user-permissions {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
  padding-top: 8px;
  border-top: 1px dashed var(--border-strong);
}
.admin-perm-section { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.admin-perm-label {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  min-width: 130px;
}
.admin-user-identity { display: flex; align-items: center; gap: 10px; min-width: 160px; }
.admin-user-name { font-weight: 600; color: var(--text-primary); font-family: var(--font-mono); }
.admin-user-role {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 2px 8px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
}
/* .admin-role-admin/.admin-role-user removed (Slice 1, NEO_DESIGN_AUDIT.md
   migration): zero remaining template references -- admin_requests.html's
   role/status pill uses .admin-request-status-* instead (see admin.css's
   own Phase 7 comment on why that split happened). Confirmed dead before
   removal, not assumed. */
.admin-user-feeds-all { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-tertiary); }
.admin-feed-form { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.admin-checkbox-inline { font-size: 0.74rem; padding: 4px 10px 4px 8px; }
.admin-feed-save {
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 5px 12px;
  border-radius: 5px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.admin-feed-save:hover { color: var(--text-primary); border-color: var(--text-tertiary); }
/* Real disabled state -- .admin-feed-save is used for the Enroll-selected
   button (disabled while nothing's checked, or mid-submit) and the enroll
   modal's confirm button, neither of which had a distinct disabled look
   before this pass; without one, "disabled" and "enabled" were visually
   identical. */
.admin-feed-save:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; }
.admin-feed-save:disabled:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.admin-delete-btn {
  background: none;
  border: 1px solid rgba(255, 59, 78, 0.35);
  color: var(--tier-unknown);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 5px 12px;
  border-radius: 5px;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.admin-delete-btn:hover { background: rgba(255, 59, 78, 0.12); border-color: var(--tier-unknown); }
.admin-delete-note {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-tertiary);
  font-style: italic;
}
.admin-delete-confirm { border-color: rgba(255, 59, 78, 0.5); color: var(--tier-unknown); }
.admin-delete-confirm:hover { color: var(--tier-unknown); border-color: var(--tier-unknown); background: rgba(255, 59, 78, 0.12); }

/* ---------- /admin/content ---------- */
.content-page-note {
  max-width: 1400px;
  margin: 0 auto 16px;
  font-size: 0.82rem;
  color: var(--text-secondary);
  line-height: 1.6;
}
.content-page-note code {
  font-family: var(--font-mono);
  font-size: 0.85em;
  color: var(--text-primary);
  background: var(--bg-surface-raised);
  padding: 1px 5px;
  border-radius: 3px;
}
.content-page-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  max-width: 1400px;
  margin: 0 auto 20px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border-subtle);
}
.content-page-tab {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}
.content-page-tab:hover { background: var(--bg-surface-raised); color: var(--text-primary); }
.content-page-tab.active {
  background: rgba(76, 111, 165, 0.16);
  color: var(--violet-soft);
  border-color: rgba(76, 111, 165, 0.35);
}
/* Color-contrast fix (found by admin-a11y.spec.js, 2026-08-22): opacity
   0.6 over .content-page-tab's --text-secondary blends down to an
   effective ~3.82:1 against --bg-void -- fails WCAG 2 AA's 4.5:1
   minimum. Using --text-tertiary directly (no opacity) reads as the same
   intended "dimmer, has-no-content" visual step down from the default/
   active tab colors (it's already one level down in this codebase's own
   --text-secondary/--text-tertiary hierarchy) while computing to a real
   4.88:1 against --bg-void -- independently verified via the same WCAG
   relative-luminance formula axe-core uses, not just eyeballed. */
.content-page-tab-empty { opacity: 1; color: var(--text-tertiary); }
.content-empty-state {
  max-width: 1400px;
  margin: 0 auto;
  padding: 32px;
  text-align: center;
  color: var(--text-tertiary);
  font-size: 0.88rem;
  border: 1px dashed var(--border-subtle);
  border-radius: 10px;
}
.content-sections {
  display: flex;
  flex-direction: column;
  gap: 16px;
  max-width: 1400px;
  margin: 0 auto;
}
.content-fields { padding: 14px 18px 18px; display: flex; flex-direction: column; gap: 18px; }
.content-field { display: flex; flex-direction: column; gap: 6px; }
.content-field label {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  margin: 0;
}
.content-field-input {
  width: 100%;
  padding: 9px 12px;
  border-radius: 6px;
  border: 1px solid var(--border-strong);
  background: var(--bg-void);
  color: var(--text-primary);
  font-size: 0.85rem;
  font-family: var(--font-body);
  margin-bottom: 0;
  resize: vertical;
}
.content-field-save {
  align-self: flex-start;
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 5px 14px;
  border-radius: 5px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.content-field-save:hover { color: var(--text-primary); border-color: var(--text-tertiary); }

.content-image-row { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
.content-image-thumb {
  width: 120px;
  height: 90px;
  object-fit: cover;
  border-radius: 6px;
  border: 1px solid var(--border-strong);
  background: #111417;
  flex-shrink: 0;
}
.content-image-upload { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 200px; }
.content-image-input {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--text-secondary);
}
.content-image-current { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-tertiary); }
.content-image-save {
  align-self: flex-start;
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 0.74rem;
  padding: 5px 14px;
  border-radius: 5px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.content-image-save:hover:not(:disabled) { color: var(--text-primary); border-color: var(--text-tertiary); }
.content-image-save:disabled { opacity: 0.4; cursor: not-allowed; }

.content-confirm-field-label { font-family: var(--font-mono); font-size: 0.85rem; color: var(--text-primary); margin: 0 0 14px; }
.content-confirm-value {
  font-size: 0.82rem;
  color: var(--text-secondary);
  background: var(--bg-void);
  border: 1px solid #1a1d21;
  border-radius: 5px;
  padding: 8px 10px;
  margin: 0 0 12px;
  max-height: 120px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
}
#content-confirm-new { color: var(--text-primary); border-color: rgba(63, 169, 160, 0.3); }

.content-changelog { padding: 6px 18px 18px; display: flex; flex-direction: column; }
.content-changelog-row {
  padding: 10px 0;
  border-bottom: 1px solid #1a1d21;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  font-size: 0.78rem;
}
.content-changelog-row:last-child { border-bottom: none; }
.content-changelog-time { font-family: var(--font-mono); font-size: 0.7rem; color: var(--text-tertiary); white-space: nowrap; }
.content-changelog-admin { font-family: var(--font-mono); font-size: 0.72rem; color: var(--violet); }
.content-changelog-path { font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-secondary); }
.content-changelog-diff { width: 100%; color: var(--text-tertiary); font-size: 0.76rem; }
.content-changelog-old { text-decoration: line-through; opacity: 0.7; }
.content-changelog-arrow { margin: 0 6px; color: var(--text-tertiary); }
.content-changelog-new { color: var(--text-primary); }

/* "No source" — distinct from both "live" (green) and "down" (red,
   reserved for a stream that WAS connected and dropped). This is a
   config/setup state, not an active fault, so it reads as caution
   (amber) rather than alarm — and critically, it's mutually exclusive
   with "live" via setStreamStatus(), so the badge can never say
   "Streaming" while the empty-state message is showing underneath it. */
.status.no-source .dot { background: var(--tier-borderline); }

.camera-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  max-width: 1400px;
  margin: 0 auto 10px;
  font-family: var(--font-mono);
  font-size: 0.76rem;
}
.camera-toolbar-count { color: var(--text-tertiary); }
.camera-toolbar-add {
  color: var(--violet);
  text-decoration: none;
  border: 1px solid rgba(63, 169, 160, 0.3);
  border-radius: 5px;
  padding: 6px 12px;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.camera-toolbar-add:hover { border-color: var(--violet); background: rgba(63, 169, 160, 0.08); }

.camera-guide-panel { scroll-margin-top: 20px; }
.camera-guide, .roster-empty {
  padding: 14px 18px 16px;
  font-size: 0.85rem;
  color: var(--text-secondary);
  line-height: 1.7;
}
.camera-guide ol, .roster-empty ol { margin: 0; padding-left: 20px; }
.camera-guide code, .roster-empty code {
  font-family: var(--font-mono);
  font-size: 0.82em;
  color: var(--text-primary);
  background: var(--bg-surface-raised);
  padding: 1px 5px;
  border-radius: 3px;
}
.camera-guide-note { margin: 12px 0 0; font-size: 0.78rem; color: var(--text-tertiary); }
.camera-rename-list { padding: 14px 18px 4px; display: flex; flex-direction: column; gap: 10px; }
.camera-rename-row { display: flex; align-items: center; gap: 8px; }
.camera-rename-id {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  color: var(--text-tertiary);
  min-width: 60px;
  flex-shrink: 0;
}
.camera-rename-row input[type="text"] { width: auto; flex: 1; padding: 7px 10px; font-size: 0.82rem; }
.roster-empty {
  /* Shared between two contexts: the roster panel's genuine-empty state
     (rendered as a single colspan'd table row, see dashboard.html) and
     the Live Feed panel's "no camera feeds assigned" state (not in a
     table at all). Plain centered block works for both -- no grid
     ancestor to account for since the roster panel moved from a CSS
     grid to a real <table>. */
  max-width: 520px;
  margin: 0 auto;
  text-align: left;
}
.roster-empty p { margin: 0 0 8px; color: var(--text-primary); }
/* P7-C enrollment UX: visually demotes the manual-folder/enroll.py
   fallback path below the recommended guided-capture instructions
   (smaller, muted, separated by a divider) -- present, not hidden, but
   clearly secondary. */
.roster-empty-fallback {
  margin-top: 14px !important;
  padding-top: 12px;
  border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
  font-size: 0.82rem;
  /* --text-secondary, not --text-tertiary -- see .enroll-modal-recommend's
     identical comment above for why (this codebase's own already-fixed
     small-text contrast finding). */
  color: var(--text-secondary) !important;
}
.empty { padding: 18px 16px; color: #6b7078; font-size: 0.85rem; }

/* ---------- Demo page ---------- */
.demo-disclaimer {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  border: 1px solid var(--border-strong);
  padding: 5px 12px;
  border-radius: 999px;
  margin-bottom: 20px;
  /* Live-site finding: this badge could overflow the viewport at 375px
     -- as a flex item (see .showcase-demo-header) with flex-shrink:0 it
     never shrank/wrapped to fit. max-width + explicit wrapping fixes it
     regardless of which flex context it renders in. */
  max-width: 100%;
  white-space: normal;
}
.demo-viewer { margin-top: 32px; max-width: 820px; }
.demo-frame {
  position: relative;
  border: 1px solid var(--border-strong);
  border-radius: 12px;
  overflow: hidden;
  background: #000;
  aspect-ratio: 16 / 9;
}
.demo-frame-video video { width: 100%; height: 100%; object-fit: contain; display: block; }
/* Explicit pause/play control for the autoplaying demo video and the
   auto-advancing image slideshow (.demo-slideshow-toggle shares this
   same visual treatment) — WCAG 2.2.2 (Pause, Stop, Hide) requires a
   visible way to stop motion that starts automatically and lasts more
   than 5 seconds. Both branches now honor prefers-reduced-motion (start
   paused) AND expose this manual toggle regardless of that setting. */
.demo-video-toggle,
.demo-slideshow-toggle {
  position: absolute;
  right: 14px;
  bottom: 14px;
  z-index: 4;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid var(--border-strong);
  background: rgba(10, 13, 16, 0.72);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--text-primary);
  cursor: pointer;
  padding: 0;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.demo-video-toggle:hover,
.demo-video-toggle:focus-visible,
.demo-slideshow-toggle:hover,
.demo-slideshow-toggle:focus-visible { border-color: var(--violet-soft); background: var(--btn-login-tint); }
.demo-video-toggle svg,
.demo-slideshow-toggle svg { width: 16px; height: 16px; fill: currentColor; }
.demo-slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  opacity: 0;
  transition: opacity 0.4s ease, filter 0.3s ease;
}
.demo-slide.active { opacity: 1; }
.demo-frame.processing .demo-slide.active { filter: brightness(0.45) saturate(0.6); }

/* Brief "analyzing" beat between slides — a violet sweep + pulsing
   corner brackets, evoking active scanning rather than a static gallery. */
.demo-scan {
  position: absolute;
  inset: 0;
  z-index: 2;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}
.demo-frame.processing .demo-scan { opacity: 1; }
.demo-scan::before {
  content: "";
  position: absolute;
  left: 0; right: 0;
  height: 3px;
  background: linear-gradient(90deg, transparent, var(--violet) 20%, var(--violet) 80%, transparent);
  box-shadow: 0 0 16px 2px var(--violet-glow);
  animation: demo-scan-sweep 0.55s ease-in-out infinite;
}
@keyframes demo-scan-sweep {
  0%   { top: 6%; }
  50%  { top: 92%; }
  100% { top: 6%; }
}
.demo-caption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 4px 0;
}
.demo-caption-text {
  font-family: var(--font-mono);
  font-size: 0.9rem;
  font-weight: 500;
  color: var(--text-secondary);
}
.demo-caption-text.authorized { color: var(--tier-authorized); }
.demo-caption-text.borderline { color: var(--tier-borderline); }
.demo-caption-text.unknown { color: var(--tier-unknown); }
.demo-caption-text.processing { color: var(--violet); }
.demo-dots { display: flex; gap: 6px; }
.demo-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); transition: background 0.2s ease; }
.demo-dot.active { background: var(--violet); }

/* ---------- FAQ ---------- */
.faq-list { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
.faq-item {
  border: 1px solid var(--border-subtle);
  background: var(--bg-surface);
  border-radius: 10px;
  padding: 4px 20px;
}
.faq-item summary {
  padding: 16px 0;
  cursor: pointer;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 0.98rem;
  color: var(--text-primary);
  list-style: none;
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after {
  content: "+";
  font-family: var(--font-mono);
  color: var(--violet);
  font-size: 1.2rem;
  flex-shrink: 0;
  transition: transform 0.2s ease;
}
.faq-item[open] summary::after { transform: rotate(45deg); }
.faq-item p { margin: 0 0 18px; color: var(--text-secondary); font-size: 0.9rem; line-height: 1.65; }

/* ---------- Case study (Projects page) ---------- */
.case-study {
  border: 1px solid var(--border-subtle);
  background: var(--bg-surface);
  border-radius: 10px;
  padding: 28px;
}
.case-study-header { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; flex-wrap: wrap; }
.case-study-header h3 { margin: 0; font-family: var(--font-display); font-size: 1.1rem; color: var(--text-primary); }
.case-study-grid {
  display: grid;
  /* Pixel-precision implementation (2026-09-04): was three EQUAL
     columns (repeat(auto-fit, minmax(200px,1fr))) -- a recognizable
     agency/portfolio-template shape per the approved art direction.
     Approach is the substantive engineering content and now gets
     visibly more width than Challenge/Result, which flank it as
     shorter contextual/evidence statements -- deliberate imbalance,
     not decoration. */
  grid-template-columns: 1fr 1.8fr 1fr;
  gap: 32px;
}
@media (max-width: 780px) {
  .case-study-grid { grid-template-columns: 1fr; }
}
.case-study-label {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--violet);
  margin-bottom: 8px;
  font-weight: 600;
}
.case-study-block p { margin: 0; color: var(--text-secondary); font-size: 0.87rem; line-height: 1.6; }

/* ---------- Sticky mobile CTA ---------- */
.mobile-cta { display: none; }
@media (max-width: 640px) {
  body { padding-bottom: 64px; } /* keep footer content from sitting under the fixed bar */
  .mobile-cta {
    display: flex;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: var(--z-fixed);
    border-top: 1px solid var(--glass-border);
    background: rgba(14, 17, 20, 0.88);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
    padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
    gap: 10px;
  }
  .mobile-cta-link {
    flex: 1;
    min-width: 0; /* flex items default to min-width:auto, which floors
      shrinking at the label's own content width -- "Get in Touch" in
      Work Sans is wide enough at this column width to overflow the
      fixed bar without this. Found while checking mobile at 390px. */
    text-align: center;
    padding: 12px;
    border-radius: 6px;
    font-size: 0.88rem;
    font-weight: 600;
    text-decoration: none;
  }
  .mobile-cta-link.primary { background: var(--violet-button); color: var(--text-primary); }
  .mobile-cta-link.secondary { border: 1px solid var(--border-strong); color: var(--text-primary); }
}

/* ---------- Cookie notice banner ---------- */
/* A single honest acknowledgment, not a fake multi-option consent flow
   — see static/js/cookie-banner.js for why. */
.cookie-banner {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: 16px;
  z-index: var(--z-overlay);
  max-width: 720px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: 16px 20px;
  border-radius: 10px;
  border: 1px solid var(--border-strong);
  background: var(--bg-surface);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
  opacity: 0;
  transform: translateY(12px);
  transition: opacity 0.3s ease, transform 0.3s ease;
}
.cookie-banner.is-visible { opacity: 1; transform: translateY(0); }
.cookie-banner-text {
  flex: 1;
  min-width: 220px;
  margin: 0;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--text-secondary);
}
.cookie-banner-text a { color: var(--violet); margin-left: 4px; }
.cookie-banner-ok {
  flex: 0 0 auto;
  padding: 10px 22px;
  font-size: 0.85rem;
}
@media (max-width: 640px) {
  .cookie-banner { bottom: 74px; } /* clear the fixed mobile CTA bar */
}

/* ============================================================
   SHAPE REBUILD -- rounder/glow visual language, scoped to
   body.shape-rounded (Home only, layered on top of theme-cold-steel).
   Additive overrides only: nothing above this block was edited, so
   every other page keeps its current sharp-corner shape language
   untouched. Applies generous radii, pill buttons, soft accent-glow
   decoration, spacing rhythm, and a browser-chrome frame component
   for showing real product screenshots -- reference: Shelve.cloud /
   Codefortify.ai's structural language, not their color (this site
   keeps the Cold Steel teal throughout; no pink was imported).
   Status: Home only. Not yet applied anywhere else. ============ */
body.shape-rounded {
  --radius-lg: 20px;
  --radius-md: 16px;
  --radius-sm: 12px;
}

body.shape-rounded .btn-primary,
body.shape-rounded .btn-secondary,
body.shape-rounded .inline-cta-link {
  border-radius: var(--radius-pill);
}
body.shape-rounded .btn-primary,
body.shape-rounded .btn-secondary {
  padding: 13px 28px;
}
body.shape-rounded .inline-cta-link {
  display: inline-block;
  padding: 10px 20px;
  border: 1px solid var(--border-strong);
  border-bottom: 1px solid var(--border-strong);
}
body.shape-rounded .inline-cta-link:hover { border-color: var(--violet-soft); }

body.shape-rounded .hud-panel,
body.shape-rounded .faq-item {
  border-radius: var(--radius-lg);
}

/* Generous internal padding + section rhythm. */
body.shape-rounded .hud-panel-header { padding: 16px 24px; }
body.shape-rounded .hud-row { padding: 14px 24px; }
body.shape-rounded .section-title { margin-top: 8px; }

/* .glow-orb and .browser-frame* removed (Phase 2 homepage rewrite) --
   confirmed homepage-exclusive: the closing-band orb and the fake
   browser-chrome mockup are both gone from the new homepage (real
   screenshot shown at full weight, no manufactured chrome around it;
   see .hp-hero-evidence / .hp-closing above). */
