/* AirfieldDaily FieldLog — mobile-first base styles.
   Design system fleshed out in Task #7; this is the initial reset + tokens
   so nothing renders unstyled while auth/security land first. */

/* Light theme, palette drawn from the Alder Airfield Services logo: navy
   (icon mark / body text), slate gray (icon mark / muted text), deep red
   (the bold "ALDER" wordmark — primary brand accent), and teal (the
   "AIRFIELD SERVICES" subtitle — secondary/gradient-partner accent). */
/* ==========================================================================
   Global [hidden] backstop (2026-08-04)

   The browser's own `[hidden] { display: none }` lives in the user-agent
   stylesheet at specificity (0,1,0), so ANY author rule setting `display` on
   the same element — `.form-notice { display: flex }`, `.card { display:
   grid }` — wins, and toggling the `hidden` attribute from JS silently does
   nothing. No error, no visual clue: the element just stays on screen.

   This codebase had patched that one element at a time NINE separate times
   (.notification-dropdown, [data-when], .app-menu-dropdown, .skeleton-group,
   .connectivity-status, [data-admin-explanation-group], .time-rounding-note,
   [data-pending-offline-drafts], .portal-active-filters) and a sweep still
   found three more live: the push opt-in row rendered a dead "Enable push
   notifications" button on browsers that don't support it, the weekly-report
   AI banner told users "don't close or reload this page" from page load
   before they'd clicked anything, and an empty week-preview strip painted on
   every New Weekly Report.

   Repeating the attribute selector gives (0,2,0), which beats every
   single-class `display` rule in this app. It does NOT beat an ID, and
   style.css already carries five ID-based display rules (#auth-boot,
   #work-entries, #visitor-entries, #labor-entries, #equipment-entries).
   None of those elements is hidden-toggled today, so nothing is broken —
   but "safe as long as nobody adds `hidden` to an id-addressed element"
   is exactly the kind of conditional guarantee that produced the twelve
   incidents above.

   Hence `!important`, deliberately, as a THIRD documented exception to this
   project's no-!important rule. The other two exist to out-shout a specific
   `!important` in the ported reference stylesheet; this one is different in
   kind — it asserts the semantic meaning of the attribute itself. `hidden`
   means the element is not to be rendered, in every case, with no exception
   worth expressing in CSS. Verified before adding: every one of the ~10
   pre-existing `[hidden]` rules in this codebase sets `display: none`, so
   there is nothing to conflict with, and this is what normalize.css,
   sanitize.css, Bootstrap and Tailwind all ship for the same reason.

   New code should NOT need another one-off override. If an element with
   `hidden` is still visible, that's a bug in this rule's reach, not a reason
   to add a tenth patch.
   ========================================================================== */
[hidden] {
  display: none !important;
}

/* ---------------------------------------------------------------------------
   Sticky-header jump offset (2026-08-18)

   Every in-page jump — progress rail chips, section jump buttons, the progress
   pill sheet, ?anchor= redirects, plain #fragment links — used to land its
   target underneath the sticky .app-header. Measured on the production daily
   report editor: sections carried scroll-margin-top: 24px against a 77px
   header, so the section number, entry count and collapse control were sliced
   off on arrival.

   scroll-padding-top on the scroll container fixes every anchor target in the
   app at once, rather than per-target scroll-margin-top which has to be
   remembered on each new one.

   The values below are FALLBACKS for a browser that never runs
   sticky-header-offset.js, which measures the header and overwrites the
   variable exactly. They are approximate on purpose: .app-header is
   flex-wrap: wrap, so the wrap point depends on how many nav items the
   signed-in role carries, and no fixed breakpoint is right for everyone.
   --------------------------------------------------------------------------- */
:root {
  /* Single-row header (~77px) plus breathing room. */
  --sticky-header-offset: 96px;
}

/* Between the hamburger breakpoint and roughly the width where a full nav
   stops fitting, the header wraps to two rows and about doubles in height. */
@media (max-width: 940px) {
  :root {
    --sticky-header-offset: 140px;
  }
}

/* At the hamburger breakpoint the nav links and user block are hidden, so the
   header is a single compact row again. */
@media (max-width: 640px) {
  :root {
    --sticky-header-offset: 84px;
  }
}

html {
  scroll-padding-top: var(--sticky-header-offset);
}

:root {
  --color-bg: #f4f6f9;
  --color-surface: #ffffff;
  --color-text: #182634;
  --color-muted: #5c6b7a;
  --color-accent: #9c2222;
  --color-accent-2: #0f5257;
  --color-danger: #dc2626;
  --color-success: #0d9488;
  --hairline: #dde3ea;
  --field-bg: #f8fafc;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 1rem;
  --space-4: 1.5rem;
  --radius: 0.75rem;
  color-scheme: light;
}

* {
  box-sizing: border-box;
}

.mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}

/* Secondary/explanatory text. Deliberately a real defined utility rather than
   a convention — this codebase has repeatedly shipped class names with no
   matching CSS rule (see the `section-card--incomplete` fix), so anything used
   in markup must actually exist somewhere the page loads. style.css is
   included globally via partials/head.ejs, so this is available everywhere. */
.muted {
  color: var(--color-muted);
  font-size: 0.9rem;
}

.visually-hidden-defs {
  position: absolute;
}

html,
body {
  margin: 0;
  padding: 0;
}

body {
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

/* 2026-08-02 bug fix, user-reported (iOS zooms in on every input tap):
   a baseline floor for every real form control, not just the ones
   wrapped in .form-field (see that class's own more specific override
   further down for the full explanation) — catches any input/select/
   textarea elsewhere in the app that doesn't set its own font-size and
   would otherwise inherit a sub-16px value from whatever ancestor it's
   nested in. Deliberately low-specificity (three bare type selectors) so
   any page/component that genuinely wants a different, intentional size
   can still override it normally — this is a safety-net default, not a
   forced value. */
input,
select,
textarea {
  font-size: 16px;
}

/* The app has two page containers: this one (8 pages — the plain admin
   screens, legal, errors) and .app-main from the reference sheet (30 pages,
   `width: min(1180px, calc(100% - 40px)); margin: 0 auto`). This one had NO
   max-width at all, so content spanned the full viewport.

   Measured at 1920px on /admin/privacy-requests before this change: the
   free-text "details" cell rendered 1075px wide — 137 characters per line,
   against a 45-75 ideal and a ~90 tolerable ceiling. The same applies to
   /admin/deleted-projects and /admin/disabled-users, which carry free-text
   reason and detail columns.

   1180px deliberately matches .app-main rather than being a new number, so
   the app converges on ONE content width instead of gaining a third.

   Checked before adding this, not assumed:
     - .legal-page (line ~2216) sets its own tighter max-width: 42rem. Same
       specificity (0,1,0) but declared LATER, so it still wins — verified by
       measurement: /legal/terms holds at 71 chars/line either way. (An
       earlier draft of this audit claimed the legal pages were unconstrained;
       measuring disproved it. They were already correct.)
     - .page--centered (errors, unprovisioned) is a centred flex column with
       short content — a max-width only improves it.
     - /admin's .admin-grid is `auto-fill minmax(14rem, 1fr)`, so its tiles
       reflow into the narrower container rather than clipping. */
.page {
  min-height: 100dvh;
  max-width: 1180px;
  margin-inline: auto;
  padding: var(--space-4);
}

.page--centered {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--space-2);
}

/* ==========================================================================
   Auth shell: login / forgot-password / reset-password.
   Adapted from the VolTech Precision login page design (same boot-sequence
   + staggered-reveal pattern), recolored to the Alder Airfield Services
   brand palette (navy/gray from the logo mark, deep red from the "ALDER"
   wordmark, teal from the "AIRFIELD SERVICES" subtitle) with a
   construction-blueprint grid motif instead of VolTech's aviation grid.
   ========================================================================== */

.auth-page {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
  position: relative;
  overflow-x: hidden;
  background:
    radial-gradient(900px 500px at 12% -10%, rgba(156, 34, 34, 0.09), transparent 60%),
    radial-gradient(900px 500px at 105% 110%, rgba(15, 82, 87, 0.09), transparent 60%),
    var(--color-bg);
}

.auth-page::before {
  content: "";
  position: fixed;
  inset: 0;
  background-image: linear-gradient(rgba(24, 38, 52, 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(24, 38, 52, 0.05) 1px, transparent 1px);
  background-size: 56px 56px;
  mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 80%);
  pointer-events: none;
}

/* ---------- Boot / loading sequence ---------- */
#auth-boot {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  background: var(--color-bg);
  transition: opacity 0.5s ease, visibility 0.5s ease;
}

#auth-boot.hidden {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.boot-ring {
  position: relative;
  width: 108px;
  height: 108px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.boot-ring svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.boot-ring .track {
  stroke: rgba(24, 38, 52, 0.12);
}

.boot-ring .sweep {
  stroke: url(#bootGradient);
  stroke-linecap: round;
  transform-origin: 50% 50%;
  animation: auth-spin 1.4s linear infinite;
}

@keyframes auth-spin {
  to {
    transform: rotate(360deg);
  }
}

.boot-mark {
  width: 34px;
  height: 34px;
  border-radius: 9px;
  background: linear-gradient(135deg, var(--color-accent), var(--color-accent-2));
  display: flex;
  align-items: center;
  justify-content: center;
}

.boot-mark img {
  width: 20px;
  height: 20px;
}

.boot-status {
  text-align: center;
}

.boot-label {
  font-size: 0.8rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--color-muted);
  margin-bottom: var(--space-2);
}

#bootMessage {
  font-size: 0.9rem;
  min-height: 1rem;
}

.boot-bar {
  width: 220px;
  height: 3px;
  border-radius: 3px;
  background: var(--hairline);
  overflow: hidden;
  margin-top: var(--space-3);
}

.boot-bar-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2));
  transition: width 0.25s ease;
}

/* ---------- Auth card + staggered reveal ---------- */
.auth-shell {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 26rem;
  opacity: 0;
  transform: translateY(10px) scale(0.98);
  transition: opacity 0.55s ease, transform 0.55s ease;
}

.auth-shell.in {
  opacity: 1;
  transform: none;
}

.auth-card {
  background: rgba(255, 255, 255, 0.78);
  border: 1px solid rgba(24, 38, 52, 0.08);
  border-radius: 1.25rem;
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  box-shadow: 0 30px 70px -30px rgba(24, 38, 52, 0.28);
  padding: clamp(1.75rem, 5vw, 2.5rem);
}

.reveal {
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.4s ease, transform 0.4s ease;
}

.auth-shell.in .reveal {
  opacity: 1;
  transform: none;
}

/* 2026-08-02: stagger halved (was 70ms/step, so the Sign in button at
   .reveal-6 didn't finish settling until ~820ms after reveal). Kept as a
   real entrance animation — the fields are hit-testable throughout, so this
   never gated interaction — but on the app's highest-frequency screen the
   shorter cadence reads as responsive rather than ceremonial. */
.reveal-0 { transition-delay: 0s; }
.reveal-1 { transition-delay: 0.035s; }
.reveal-2 { transition-delay: 0.07s; }
.reveal-3 { transition-delay: 0.105s; }
.reveal-4 { transition-delay: 0.14s; }
.reveal-5 { transition-delay: 0.175s; }
.reveal-6 { transition-delay: 0.21s; }
.reveal-7 { transition-delay: 0.245s; }

.brand-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.brand-mark {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 0.6rem;
  background: linear-gradient(135deg, var(--color-accent), var(--color-accent-2));
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  box-shadow: 0 8px 18px -6px rgba(156, 34, 34, 0.4);
}

.brand-mark img {
  width: 1.35rem;
  height: 1.35rem;
}

.brand-name {
  font-size: 1.25rem;
  font-weight: 700;
}

.brand-sub {
  font-size: 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-muted);
}

.auth-card h1 {
  font-size: 1.6rem;
  margin: 0 0 0.25rem;
}

.subtitle {
  color: var(--color-muted);
  margin: 0 0 var(--space-2);
}

.auth-card .subtitle {
  margin: 0 0 var(--space-4);
}

.alert {
  display: flex;
  gap: var(--space-2);
  align-items: flex-start;
  background: rgba(220, 38, 38, 0.08);
  border: 1px solid rgba(220, 38, 38, 0.25);
  color: #7f1d1d;
  font-size: 0.9rem;
  line-height: 1.5;
  padding: 0.6rem 0.75rem;
  border-radius: calc(var(--radius) / 1.5);
  margin-bottom: var(--space-3);
}

.alert[hidden] {
  display: none;
}

.alert-success {
  background: rgba(13, 148, 136, 0.1);
  border-color: rgba(13, 148, 136, 0.28);
  color: #0f4c46;
}

.auth-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  text-align: left;
}

.form-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  font-size: 0.9rem;
  color: var(--color-muted);
}

.field-input-wrap {
  position: relative;
}

.form-field input {
  width: 100%;
  font: inherit;
  /* 2026-08-02 bug fix, user-reported (iOS zooms in on every input tap):
     `font: inherit` pulls in .form-field's own deliberately-smaller
     0.9rem (14.4px) label/helper-text sizing — iOS Safari auto-zooms the
     whole page in on focus whenever a real form control's computed
     font-size is under 16px. Overriding font-size back up afterward
     (font: inherit already set every other font-shorthand property
     correctly — family, weight, line-height — this only restores the
     one that matters for iOS's threshold) stops the unwanted zoom without
     changing how the input looks on desktop/larger screens, where 16px
     was already close to the base size. */
  font-size: 16px;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.65rem 0.75rem;
}

.form-field input::placeholder {
  color: #64748b;
}

.form-field input[type="file"] {
  padding: 0.5rem;
  background: var(--color-surface);
}

.form-field select {
  width: 100%;
  font: inherit;
  /* Same iOS zoom-on-focus fix as .form-field input above. */
  font-size: 16px;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.65rem 0.75rem;
}

.form-field select:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 1px;
}

.form-hint {
  font-size: 0.8rem;
  color: var(--color-muted);
  margin: -0.25rem 0 var(--space-2);
}

.form-field input:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 1px;
}

.toggle-vis {
  position: absolute;
  right: 0.1rem;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  border: none;
  background: transparent;
  color: var(--color-muted);
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  padding: 0.3rem 0.5rem;
  cursor: pointer;
  border-radius: calc(var(--radius) / 2);
}

.toggle-vis:hover {
  color: var(--color-text);
}

.row-between {
  display: flex;
  align-items: center;
  justify-content: space-between;
  font-size: 0.85rem;
  gap: var(--space-2);
}

.remember {
  display: flex;
  align-items: center;
  min-height: 44px;
  gap: var(--space-2);
  color: var(--color-muted);
  cursor: pointer;
}

.remember input {
  width: 0.95rem;
  height: 0.95rem;
  accent-color: var(--color-accent);
}

.link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--color-accent);
  font-weight: 600;
  text-decoration: none;
  font-size: 0.85rem;
}

/* A <button> that should read as an inline text link (e.g. "Resend code"
   inside a form that can't be a plain <a href>, since submitting it needs
   to be a POST). */
.link-button {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
}

.btn-block {
  width: 100%;
}

.signup-line {
  text-align: center;
  margin-top: var(--space-3);
  font-size: 0.85rem;
  color: var(--color-muted);
}

.auth-legal-footer {
  text-align: center;
  margin-top: var(--space-4);
  font-size: 0.75rem;
  color: var(--color-muted);
}

.auth-legal-footer a {
  color: var(--color-muted);
}

.auth-legal-footer span {
  margin: 0 var(--space-1);
}

/* Honeypot: visually hidden and unreachable by keyboard/assistive tech, but
   still present in the DOM so bots that auto-fill every field trip it. See
   authController.ts — a filled honeypot silently no-ops the request instead
   of revealing that it was detected. */
.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.form-error {
  color: var(--color-danger);
  font-size: 0.9rem;
  margin: 0;
}

@media (prefers-reduced-motion: reduce) {
  .boot-ring .sweep {
    animation: none;
  }
  .auth-shell,
  .reveal {
    transition: none;
    opacity: 1;
    transform: none;
  }
}

/* ==========================================================================
   .btn aligned to the reference button system (2026-08-09)

   The app had TWO complete button systems and which one you saw depended on
   which stylesheet the page happened to load. Measured side by side:

                        radius   weight   min-height
     .primary-action     10px      800       44px     (30 pages)
     .btn-*              5.4px     600       ~43px    (11 pages + both headers)

   A ~2x difference in radius and 200 in weight, on pages a user moves between
   constantly — and the header's own Log out button is a .btn, so on any
   reference-styled page it looked like it came from a different product than
   every other button beside it.

   Only radius, weight and min-height are aligned here. `display` is
   deliberately left as inline-block rather than switched to .primary-action's
   inline-flex: .btn-block sets width:100% on the auth pages and JS builds
   .btn elements in four places (form-loading.js's confirm dialog,
   offline-review-panel.js, pwa-install-prompt.js), so changing the display
   model is real layout risk for no visual gain at this size. Padding already
   measured 45-46px tall, so min-height: 44px is insurance, not a change.
   ========================================================================== */
.btn {
  display: inline-block;
  font: inherit;
  /* 800 and 10px are .primary-action's exact values, not approximations. */
  font-weight: 800;
  text-align: center;
  text-decoration: none;
  border: none;
  border-radius: 10px;
  min-height: 44px;
  padding: 0.7rem 1rem;
  cursor: pointer;
  transition: transform 0.1s ease, opacity 0.15s ease;
}

.btn:active {
  transform: scale(0.98);
}

.btn[disabled] {
  cursor: wait;
  opacity: 0.75;
}

/* Added by public/js/form-loading.js to the submit button that was actually
   pressed, so the user gets immediate feedback that their tap registered
   before a response comes back.

   Scoped to that one button rather than `.is-submitting button[type=submit]`
   (2026-08-07, user-reported): a form with two submit buttons — approve and
   decline on a reopen request, approve and reject on a review panel — span
   BOTH of them, which reads as though both actions were running at once.
   Every submit button is still DISABLED, since that is what prevents a
   double-submit; only the spinner is now specific to the action taken. */
.is-submitting button[type="submit"].is-submitting-target::after,
.is-submitting input[type="submit"].is-submitting-target::after {
  content: "";
  display: inline-block;
  width: 0.8em;
  height: 0.8em;
  margin-left: 0.5em;
  vertical-align: -0.1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: btn-spin 0.6s linear infinite;
}

@keyframes btn-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .is-submitting button[type="submit"].is-submitting-target::after,
  .is-submitting input[type="submit"].is-submitting-target::after {
    animation: none;
  }
}

/* ==========================================================================
   .btn-primary: brand red -> primary teal (2026-08-09)

   This is the colour-MEANING fix, not a restyle. Before this, "primary action"
   was red (#9c2222) on the 11 pages using .btn-* and teal (#155e54) on the 30
   using .primary-action — while red ALSO meant destructive (.danger-action,
   .btn-danger). So the first button a user ever pressed, "Sign in", taught
   them red = proceed, and every screen after login reversed it.

   The concrete hazard that settles it is on /admin/deleted-projects:

     Restore              .btn btn-primary   #9c2222   <- safe, reversible
     Permanently delete   .btn btn-danger    #dc2626   <- irreversible purge

   The safe action and the single most destructive action in the app were both
   red, separated only by a hue shift most people will not perceive, on the one
   page where a mis-click cannot be undone. That is a safety defect rather than
   a branding preference, and it is why this change is worth its risk.

   --color-accent IS NOT TOUCHED. It has 41 usages and is the brand identity
   colour drawn from the logo wordmark: the brand-mark and boot-sequence
   gradients, .link, the nav aria-current active state, focus rings and
   checkbox accent-color. Only this one button's fill moves. The resulting
   system reads: teal = the expected next action, red = destructive, brand red
   = identity and links.

   The fallback is mandatory, not defensive. --alder-green lives in
   daily-report-reference.css's :root, and NONE of the pages that use
   .btn-primary load that sheet (auth, legal, errors, the four plain admin
   screens — all verified). Without the literal the fill would resolve to
   nothing on every single one of them.

   Contrast is preserved: white on #155e54 measures 7.61:1, against 7.88:1 for
   the red it replaces. Both clear AA comfortably.
   ========================================================================== */
.btn-primary {
  background: var(--alder-green, #155e54);
  color: #ffffff;
}

.btn-primary:hover {
  /* #104f47 is .primary-action:hover's own value, so the two systems now
     darken to the same shade rather than one fading via opacity. */
  background: #104f47;
  opacity: 1;
}

.btn-ghost {
  background: transparent;
  color: var(--color-text);
  border: 1px solid var(--hairline);
}

.btn-ghost:hover {
  background: var(--color-bg);
}

/* Deliberately left FILLED red, not aligned to .danger-action's outlined
   treatment (2026-08-09). The two are not inconsistent — they mark different
   severities, and now that .btn-primary is teal the distinction finally reads:

     .danger-action   outlined red   reversible deletes (an unsubmitted draft)
     .btn-danger      filled red     the purge control, the app's only truly
                                     irreversible action, used in exactly one
                                     place

   Making the purge outlined would render it LESS prominent than the teal
   Restore button sitting beside it, which is the wrong hierarchy for the one
   action that cannot be undone. Radius and weight are aligned via .btn above.
   Its disabled state is real (purge-confirm.js enables it only once the typed
   project name matches) and is covered by the cursor rule in fields.css. */
.btn-danger {
  background: var(--color-danger);
  color: #ffffff;
}

.btn-danger:hover {
  opacity: 0.9;
}

.btn-danger:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Purge-project "type the name to confirm" control (deleted-projects.ejs) —
   a destructive-action confirm pattern with no other user in this app yet. */
.purge-confirm {
  margin-top: var(--space-2);
  padding: var(--space-2);
  border: 1px solid rgba(220, 38, 38, 0.3);
  border-radius: calc(var(--radius) / 2);
  background: rgba(220, 38, 38, 0.05);
}

.purge-confirm summary {
  cursor: pointer;
  font-weight: 600;
  color: var(--color-danger);
}

.purge-confirm__body {
  margin-top: var(--space-2);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.purge-confirm__body input[type="text"] {
  width: 100%;
}

.purge-blocked {
  color: var(--color-muted);
  font-size: 0.85rem;
}

/* ==========================================================================
   Skip link (2026-08-09) — WCAG 2.4.1

   Hidden until focused, which is the standard pattern: it is only ever
   reachable by keyboard, so there is nothing for a mouse or touch user to see.

   :focus, NOT :focus-visible. A skip link must appear whenever it receives
   focus; :focus-visible would leave it invisible in the exact situations
   (programmatic focus, some AT navigation) where a user most needs to see
   where they have landed. This is the deliberate exception to the
   :focus-visible rule fields.css applies to buttons.

   z-index 100 clears everything it could otherwise appear behind — the sticky
   .app-header is 10, the notification and app-menu dropdowns are 50, and
   #auth-boot is 20.

   left/top rather than transform so it is genuinely off-screen when unfocused
   and cannot be reached by a stray tap.
   ========================================================================== */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 12px 18px;
  border: 2px solid var(--color-accent);
  border-radius: calc(var(--radius) / 2);
  background: var(--color-surface);
  color: var(--color-text);
  font-weight: 700;
  font-size: 0.95rem;
  text-decoration: none;
}

.skip-link:focus {
  left: var(--space-3);
  top: var(--space-3);
}

/* The target is an empty div; focusing it must not paint a stray ring around
   nothing. Focus still moves — which is the whole point of its tabindex="-1" —
   it just isn't drawn. */
#main-content:focus {
  outline: none;
}

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  background: var(--color-surface);
  border-bottom: 1px solid var(--hairline);
  position: sticky;
  top: 0;
  z-index: 10;
}

.app-header__nav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex-wrap: wrap;
}

/* 2026-08-03 user-reported fix: this div had no layout rule of its own at
   all (block by default), so the ~4px gap between Dashboard/Projects/
   Timesheets/Admin was nothing but collapsed HTML-source whitespace, not a
   deliberate value — a change to indentation in the template could have
   shrunk or grown it by accident. Matches the outer .app-header__nav's own
   gap so the brand-to-links spacing and the links-to-each-other spacing
   read as one consistent rhythm rather than two different amounts. */
.app-header__nav-links {
  display: flex;
  align-items: center;
  gap: var(--space-4);
}

.app-header__nav a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: var(--space-1) 0;
  color: var(--color-muted);
  text-decoration: none;
  font-size: 0.9rem;
  font-weight: 600;
}

.app-header__nav a:hover {
  color: var(--color-text);
}

/* 2026-08-02 HCI fix: active-page indicator. Uses a real underline + weight +
   colour shift rather than colour alone (WCAG 1.4.1), and hangs off the same
   aria-current attribute that conveys it programmatically, so the visual and
   assistive-tech signals can never drift apart. */
.app-header__nav a[aria-current="page"] {
  color: var(--color-accent);
  box-shadow: inset 0 -2px 0 var(--color-accent);
}

.app-menu-dropdown__links a[aria-current="page"] {
  color: var(--color-accent);
  font-weight: 700;
}

/* Brand is now an <a> to "/" (it looked identical to the nav links but did
   nothing when clicked — an affordance mismatch against a near-universal
   convention). Keeps its previous visual weight exactly. */
.app-header__brand {
  font-weight: 700;
  font-size: 1.1rem;
  color: var(--color-text);
  text-decoration: none;
}

.app-header__user {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: 0.85rem;
  color: var(--color-muted);
}

.app-header__user-info {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* Mobile hamburger menu — shared by both header partials (views/partials/
   app-header.ejs, the staff header, and views/partials/portal-header.ejs,
   the customer-portal header) so the two look and behave identically on a
   narrow screen, not just on desktop. Both share the same base
   .app-header/.app-header__nav/.app-header__user classes and now the same
   markup shape (.app-header__nav-links, .app-header__user-info,
   .app-header__menu) — each partial just supplies its own nav-link list
   and identity fields into it. Below the breakpoint, the full nav-link row
   and the email/role/logout block hide; only the brand, the notification
   bell, and this hamburger stay visible — the hamburger's own dropdown
   duplicates the user's identity + every nav link they're allowed to see +
   logout, so nothing is actually lost, just moved off the cramped top bar. */
.app-header__menu {
  display: none;
  position: relative;
}

.app-header__menu-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: none;
  border: none;
  border-radius: 999px;
  color: var(--color-muted);
  cursor: pointer;
}

.app-header__menu-toggle:hover {
  background: var(--color-bg);
  color: var(--color-text);
}

/* Three-line hamburger that animates into an X on open — pure CSS, driven
   off the button's own aria-expanded attribute (already toggled by
   app-menu.js for accessibility, so no separate JS-managed visual class is
   needed). */
.hamburger-icon {
  position: relative;
  width: 20px;
  height: 14px;
}

.hamburger-icon span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 2px;
  border-radius: 2px;
  background: currentColor;
  transition: transform 0.25s ease, opacity 0.2s ease, top 0.25s ease;
}

.hamburger-icon span:nth-child(1) {
  top: 0;
}

.hamburger-icon span:nth-child(2) {
  top: 6px;
}

.hamburger-icon span:nth-child(3) {
  top: 12px;
}

.app-header__menu-toggle[aria-expanded="true"] .hamburger-icon span:nth-child(1) {
  top: 6px;
  transform: rotate(45deg);
}

.app-header__menu-toggle[aria-expanded="true"] .hamburger-icon span:nth-child(2) {
  opacity: 0;
}

.app-header__menu-toggle[aria-expanded="true"] .hamburger-icon span:nth-child(3) {
  top: 6px;
  transform: rotate(-45deg);
}

.app-menu-dropdown {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  width: min(280px, 80vw);
  display: flex;
  flex-direction: column;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
  z-index: 50;
  overflow: hidden;
  animation: app-menu-in 0.15s ease;
}

/* Same "author display:flex beats the [hidden] user-agent default" fix
   already documented on .notification-dropdown[hidden]. */
.app-menu-dropdown[hidden] {
  display: none;
}

@keyframes app-menu-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.app-menu-dropdown__user {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-3);
  border-bottom: 1px solid var(--hairline);
}

.app-menu-dropdown__user strong {
  color: var(--color-text);
  font-size: 0.95rem;
}

.app-menu-dropdown__user span {
  color: var(--color-muted);
  font-size: 0.8rem;
}

.app-menu-dropdown__links {
  display: flex;
  flex-direction: column;
  padding: var(--space-1) 0;
}

.app-menu-dropdown__links a {
  display: flex;
  align-items: center;
  min-height: 44px;
  padding: 0 var(--space-3);
  color: var(--color-text);
  text-decoration: none;
  font-size: 0.9rem;
  font-weight: 600;
}

.app-menu-dropdown__links a:hover {
  background: var(--color-bg);
}

.app-menu-dropdown__logout {
  padding: var(--space-2) var(--space-3) var(--space-3);
  border-top: 1px solid var(--hairline);
}

.app-menu-dropdown__logout .btn {
  width: 100%;
}

@media (max-width: 640px) {
  .app-header__nav-links,
  .app-header__user-info {
    display: none;
  }

  .app-header__menu {
    display: flex;
  }

  /* 2026-08-02 bug fix, caught during testing at the narrowest real device
     width (320px): .app-header still had its original flex-wrap:wrap from
     the pre-hamburger layout — with nav-links/user-info hidden, the only
     remaining content is the brand (fairly long post-rebrand: "AirfieldDaily
     FieldLog") plus the bell+hamburger group, and at 320px those two don't
     quite fit on one line, so the row wrapped anyway, landing
     .app-header__user back near the LEFT edge — reopening the exact
     notification-dropdown positioning bug just fixed above, whose fix
     assumes .app-header__user sits at the header's right edge, not wrapped
     onto its own left-starting row. Forced to a single row instead;
     min-width:0 is required on the flex item for content to actually be
     allowed to shrink/truncate rather than force an overflow-driven wrap —
     browsers default a flex item's min-width to `auto` (effectively its
     content's natural width), which silently defeats flex-shrink/ellipsis
     otherwise. */
  .app-header {
    flex-wrap: nowrap;
  }

  .app-header__nav {
    min-width: 0;
    flex: 1 1 auto;
  }

  .app-header__user {
    flex-shrink: 0;
  }

  /* The plain-text staff brand truncates with an ellipsis if it doesn't
     fit — a single text run, so text-overflow applies cleanly. */
  .app-header__brand {
    display: block;
    min-width: 0;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  /* The portal brand is NOT a single text run (an icon mark + a two-line
     "AirfieldDaily FieldLog" / "Client Portal" text stack, its own nested
     flex layout) — text-overflow:ellipsis doesn't apply meaningfully to
     that shape, and forcing display:block here would break the icon+text
     side-by-side arrangement. min-width:0 alone is enough to let it shrink
     below its content's natural width instead of forcing a wrap; at true
     320px widths the icon mark and short brand text still comfortably fit
     without truncation, unlike the plain header's longer inline text. */
  .portal-header__brand {
    min-width: 0;
  }
}

/* Notification bell + dropdown (public/js/notifications.js) */
.notification-bell {
  position: relative;
}

.notification-bell__toggle {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  background: none;
  border: none;
  border-radius: 999px;
  color: var(--color-muted);
  cursor: pointer;
}

.notification-bell__toggle:hover {
  background: var(--color-bg);
  color: var(--color-text);
}

.notification-bell__badge {
  position: absolute;
  top: 4px;
  right: 4px;
  min-width: 1.1rem;
  height: 1.1rem;
  padding: 0 0.3rem;
  border-radius: 999px;
  background: var(--color-danger);
  color: #ffffff;
  font-size: 0.65rem;
  font-weight: 700;
  line-height: 1.1rem;
  text-align: center;
}

.notification-dropdown {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  width: min(360px, 90vw);
  max-height: 420px;
  display: flex;
  flex-direction: column;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16);
  z-index: 50;
  overflow: hidden;
}

/* The class rule's `display: flex` above is author CSS, which beats the
   browser's own [hidden] default (user-agent stylesheet, lowest cascade
   priority) even though that default is "display: none" — same bug class
   documented elsewhere in this app (see the daily-report editor's
   data-when[hidden] fix). Without this, toggling the `hidden` attribute
   from JS silently does nothing. */
.notification-dropdown[hidden] {
  display: none;
}

/* 2026-08-02 bug fix, user-reported ("cut off" on mobile): this rule was
   written for the OLD header layout, where a narrow viewport made the
   entire .app-header__user block wrap onto its own row (via .app-header's
   flex-wrap), landing the bell near the viewport's LEFT edge — left:0
   (relative to .notification-bell, its positioned ancestor) correctly
   reached the screen edge in that world. The hamburger-menu redesign (see
   .app-header__menu above) no longer wraps the header on mobile at all —
   nav links and the email/role/logout block hide instead, so the bell now
   sits mid-row, to the LEFT of the hamburger button, never at either
   edge. left:0 relative to the bell now anchors a near-viewport-width
   panel starting well INTO the screen, running it off the RIGHT edge
   instead — the reported cutoff.
   Fixed by moving the positioning root from .notification-bell up to
   .app-header__user, which (via .app-header's own
   justify-content:space-between) sits flush against the header's true
   right edge regardless of exactly where the bell/hamburger fall within
   it, then anchoring from the right — mirroring the already-correct
   desktop rule instead of re-deriving a separate left-anchored special
   case. .notification-bell only ever needed position:relative to give its
   own dropdown child a positioning root — moving that role to
   .app-header__user at this breakpoint doesn't affect anything else (the
   bell's unread-count badge is independently anchored to
   .notification-bell__toggle, not this element). */
@media (max-width: 640px) {
  .notification-bell {
    position: static;
  }

  .app-header__user {
    position: relative;
  }

  .notification-dropdown {
    left: auto;
    right: 0;
    width: min(320px, calc(100vw - 2rem));
  }
}

.notification-dropdown__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--hairline);
  font-weight: 700;
  color: var(--color-text);
}

.notification-dropdown__header .link-button {
  font-weight: 600;
  font-size: 0.85rem;
  color: var(--color-accent);
}

.notification-dropdown__list {
  list-style: none;
  margin: 0;
  padding: 0;
  overflow-y: auto;
}

/* One notification: the link, plus its dismiss button (2026-08-07).
   The separator lives here on the row rather than on the link inside it.
   It used to be on `.notification-dropdown-item` with a `:last-child`
   override — but each link was the ONLY child of its own <li>, so it
   matched `:last-child` every time and the border was cancelled on every
   row, meaning separators never actually rendered. Adding the dismiss
   button would have flipped that the other way (the link stops being the
   last child, so borders would appear on every row including the final
   one). Moving it to the <li> is correct for both. */
.notification-dropdown-row {
  display: flex;
  align-items: stretch;
  border-bottom: 1px solid var(--hairline);
}

.notification-dropdown-row:last-child {
  border-bottom: none;
}

.notification-dropdown-item {
  display: block;
  flex: 1;
  min-width: 0; /* a flex item defaults to min-width:auto, which would let a long message widen the dropdown instead of wrapping */
  padding: var(--space-2) var(--space-3);
  color: var(--color-text);
  text-decoration: none;
  font-size: 0.85rem;
}

.notification-dropdown-row:hover {
  background: var(--color-bg);
}

.notification-dropdown-item--unread {
  font-weight: 700;
}

/* Tint the whole row, not just the link — otherwise the unread highlight
   would stop short of the dismiss button and leave a pale notch. */
.notification-dropdown-row:has(.notification-dropdown-item--unread) {
  background: rgba(156, 34, 34, 0.05);
}

.notification-dropdown-row__dismiss {
  flex: 0 0 auto;
  min-width: 44px; /* real touch target — this sits next to a link that navigates away */
  border: none;
  background: none;
  padding: 0 var(--space-2);
  font-size: 1.1rem;
  line-height: 1;
  color: var(--color-muted);
  cursor: pointer;
}

.notification-dropdown-row__dismiss:hover,
.notification-dropdown-row__dismiss:focus-visible {
  color: var(--color-danger);
}

.notification-dropdown-row__dismiss[disabled] {
  opacity: 0.5;
  cursor: default;
}

/* Two actions now share the header row.
   Gap widened from --space-2 (8px) to --space-3 (16px) on 2026-08-09. The two
   controls have OPPOSITE consequences — "Mark all read" is reversible, "Clear
   read" permanently deletes read notifications — and once both became real
   44px targets (below) an 8px separation between them was too tight for a
   thumb. Verified after widening that the header still fits at 320px without
   overflowing; see the touch-target note below. */
.notification-dropdown__actions {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

/* ==========================================================================
   Notification dropdown touch targets (2026-08-09) — WCAG 2.5.8

   Measured in the real dropdown before this change: "Mark all read" 81x20,
   "Clear read" 63x20, "Enable push notifications" 155x20 — all three well
   under the 44px minimum, in a dropdown, on a phone, for a field user who may
   be wearing gloves.

   PADDING, NOT A ::before HIT AREA — this is the load-bearing decision.

   The technique used elsewhere in this app (.daily-entry-action > summary,
   the date/month-picker arrows) expands a small control with
   `::before { inset: -11px }`, keeping it visually compact. That is wrong
   here, twice over:

     1. "Mark all read" and "Clear read" sat 8px apart. Expanding each by 11px
        in every direction would make their hit areas OVERLAP by 14px — a
        mis-tap between "mark these as read" and "delete these permanently".
        Exactly the hazard the 58px offset on the daily-report edit/delete pair
        was written to avoid.
     2. .notification-dropdown has `overflow: hidden`, so a negative-inset
        ::before would be clipped at the panel edge anyway and never reach the
        full 44px.

   Growing the box with padding cannot overlap a sibling — flex `gap` keeps
   them apart no matter how large each one gets. So padding is the correct tool
   for adjacent controls, and ::before stays the right tool only for an
   ISOLATED small control.

   The rows' own vertical padding is dropped to compensate, so each row grows
   37px -> 45px rather than 37px -> 61px. Horizontal padding is deliberately
   NOT added: the header is `justify-content: space-between` with no wrap, and
   at 320px the panel's content box is only ~256px wide — extra horizontal
   padding on both buttons would push the row into overflow.
   ========================================================================== */
.notification-dropdown__actions .link-button,
.notification-dropdown__push .link-button {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

.notification-dropdown__header,
.notification-dropdown__push {
  padding-block: 0;
}

.notification-dropdown-item__time {
  display: block;
  margin-top: var(--space-1);
  font-weight: 400;
  font-size: 0.75rem;
  color: var(--color-muted);
}

.notification-dropdown__empty {
  padding: var(--space-3);
  text-align: center;
  color: var(--color-muted);
  font-size: 0.85rem;
}

.notification-dropdown__push {
  display: flex;
  align-items: center;
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--hairline);
}

.notification-dropdown__push .link-button {
  font-weight: 600;
  font-size: 0.85rem;
}

.notification-dropdown__push-status {
  padding: 0 var(--space-3) var(--space-2);
  color: var(--color-muted);
  font-size: 0.78rem;
}

.card {
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: var(--space-4);
  max-width: 36rem;
  margin: var(--space-4) auto 0;
  box-shadow: 0 1px 2px rgba(24, 38, 52, 0.06), 0 1px 3px rgba(24, 38, 52, 0.08);
  animation: card-in 0.35s ease;
}

.admin-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: var(--space-3);
}

.admin-tile {
  display: block;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: var(--space-4);
  text-decoration: none;
  color: inherit;
  box-shadow: 0 1px 2px rgba(24, 38, 52, 0.06), 0 1px 3px rgba(24, 38, 52, 0.08);
  transition: transform 0.1s ease, box-shadow 0.15s ease;
}

.admin-tile:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 10px rgba(24, 38, 52, 0.12);
}

.admin-tile h2 {
  margin: 0 0 var(--space-1);
  font-size: 1.1rem;
  color: var(--color-accent);
}

.admin-tile p {
  margin: 0;
  color: var(--color-muted);
  font-size: 0.9rem;
}

@keyframes card-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.skeleton-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

/* Author CSS's `display: flex` above otherwise beats the browser's default
   `[hidden] { display: none }` UA rule, so setting `.hidden = true` in JS
   would silently do nothing and the shimmer would run forever underneath
   the real content once it appears. */
.skeleton-group[hidden] {
  display: none;
}

.skeleton--title {
  height: 1.25rem;
  width: 40%;
}

.skeleton--line {
  height: 1rem;
  width: 90%;
}

.skeleton--line-short {
  width: 75%;
}

.page-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.page-header h1 {
  margin: 0;
}

.page-header__actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.page-header__actions form {
  display: inline-flex;
}

.phase-card {
  text-align: left;
  margin-left: 0;
  margin-right: 0;
  max-width: none;
}

.inline-form {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: var(--space-3);
}

.inline-form input[type="text"],
.inline-form input[type="number"] {
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.5rem 0.65rem;
}

.inline-form__narrow {
  width: 5rem;
}

.daily-report-form textarea {
  width: 100%;
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.65rem 0.75rem;
  resize: vertical;
}

.phase-fieldset {
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: var(--space-3);
  margin: 0;
}

.phase-fieldset legend {
  font-weight: 700;
  padding: 0 var(--space-1);
}

.repeatable-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  align-items: flex-start;
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--hairline);
}

.repeatable-row--inline {
  align-items: center;
}

.repeatable-row input[type="text"],
.repeatable-row input[type="number"] {
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.4rem 0.6rem;
}

.repeatable-row textarea {
  flex: 1 1 16rem;
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.4rem 0.6rem;
  resize: vertical;
}

/* Stacked "card" repeatable rows (Location/Work, Visitors, Labor, Equipment)
   — each row is its own labeled mini-form instead of a cramped inline strip,
   so it's clear what each field is for even once placeholder text is gone. */
#work-entries,
#visitor-entries,
#labor-entries,
#equipment-entries {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin-bottom: var(--space-2);
}

.repeatable-row--stacked {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  background: var(--field-bg);
}

.repeatable-row__fields {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
}

.repeatable-row__field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  flex: 1 1 14rem;
  font-size: 0.85rem;
  color: var(--color-muted);
}

.repeatable-row__field--narrow {
  flex: 0 1 9rem;
}

.repeatable-row__field input,
.repeatable-row__field textarea {
  width: 100%;
  font: inherit;
  font-size: 1rem;
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.6rem 0.7rem;
}

.repeatable-row__field textarea {
  resize: vertical;
}

.repeatable-row--stacked .remove-row {
  align-self: flex-end;
}

.attachment-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: var(--space-3);
}

.attachment-tile {
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: var(--space-2);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  background: var(--field-bg);
}

.attachment-tile__media {
  width: 100%;
  max-height: 14rem;
  object-fit: contain;
  border-radius: calc(var(--radius) / 1.5);
  background: #0000000d;
}

.attachment-tile__status {
  color: var(--color-muted);
  font-style: italic;
}

.attachment-tile__meta {
  font-size: 0.8rem;
  color: var(--color-muted);
  margin: 0;
}

/* Attachment capture (new daily report form) — big camera-first buttons
   rather than a plain file input, since users are taking a photo/video, not
   browsing for an existing file. */
.attachment-capture {
  display: flex;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
}

/**
 * Capture controls: "Take Photo", "Take Video", "Choose from Device", and the
 * same pattern on the projects page for files and logos.
 *
 * 2026-08-14, RPR-reported: "the user can't tell if take video / take photo
 * are buttons". Measured on the live editor at 375px, and they were right —
 * this was styled as a DROP ZONE, not a control:
 *
 *   fill    #f8fafc (--field-bg, the FORM FIELD background)  1.05:1 vs the card
 *   border  dashed 1.6px #dde3ea (--hairline)                1.29:1 vs the card
 *
 * WCAG 1.4.11 asks 3:1 for a control boundary. Worse, the only brand colour in
 * the whole control lived on :hover, which never fires on a touchscreen — so on
 * a phone there was nothing to indicate it was pressable at any point.
 *
 * The root cause was scope: when form controls were consolidated onto shared
 * --control-* tokens, these were left behind on the old --field-bg/--hairline
 * pair. They are buttons, not fields, so they take a solid button treatment
 * instead — navy on white is ~12:1, comfortably past the requirement.
 *
 * The 2-up-then-1 wrap from `flex: 1 1 9rem` is deliberate and kept: it gives
 * the longest label its own full-width row rather than squeezing three columns.
 */
.btn-camera {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  flex: 1 1 9rem;
  min-height: 6rem;
  padding: var(--space-3);
  font: inherit;
  font-weight: 650;
  font-size: 0.95rem;
  /* Fallbacks matter: --alder-* is defined in daily-report-reference.css, which
     the projects page also loads — but the literal keeps this correct on any
     page that ever reuses the component without it. */
  color: var(--alder-navy, #003c55);
  background: var(--color-surface, #fff);
  border: 2px solid var(--alder-navy, #003c55);
  border-radius: var(--radius);
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease;
}

/* :focus-visible as well as :hover — a touch device reaches neither, which is
   why the resting state above had to carry the affordance on its own. */
.btn-camera:hover,
.btn-camera:focus-visible {
  background: #eef4f7;
}

.btn-camera__icon {
  font-size: 1.75rem;
  line-height: 1;
}

/* The fallback action ("Choose from Device" / "Attach PDF"), so it recedes —
   but by WEIGHT, never by weakening the boundary. Dropping the border back to a
   hairline here is exactly the bug that was just fixed above. */
.btn-camera--secondary {
  font-weight: 500;
  border-width: 1.5px;
}

.attachment-preview-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}

.attachment-preview-chip {
  position: relative;
  width: 6rem;
  height: 6rem;
  border-radius: calc(var(--radius) / 1.5);
  overflow: hidden;
  border: 1px solid var(--hairline);
  background: var(--color-surface);
}

.attachment-preview-chip img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.attachment-preview-chip__video {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  background: var(--field-bg);
}

.attachment-preview-chip__label {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  font-size: 0.6rem;
  color: #fff;
  background: rgba(0, 0, 0, 0.6);
  padding: 0.1rem 0.3rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 2026-08-02 touch-ergonomics fix: measured ~21px, sitting directly beside
   the highest-frequency capture action in the app (Take Photo). The visible
   badge stays small so it doesn't cover the thumbnail it sits on, so the hit
   area is extended invisibly via ::before — same technique as
   .date-picker__day / .date-picker__nav above. */
.attachment-preview-chip__remove {
  position: absolute;
  top: 0.15rem;
  right: 0.15rem;
  width: 1.3rem;
  height: 1.3rem;
  line-height: 1;
  border-radius: 50%;
  border: none;
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: 0.9rem;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}

.attachment-preview-chip__remove::before {
  content: "";
  position: absolute;
  inset: -11px;
}

.attachment-preview-chip__remove:hover {
  background: var(--color-danger);
}

/* Approval workflow: status badges, draft/revision banners, review actions */
.status-badge {
  display: inline-flex;
  align-items: center;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.status-badge--draft {
  background: #e2e8f0;
  color: var(--color-muted);
}

.status-badge--submitted {
  background: #fef3c7;
  color: #92400e;
}

.status-badge--approved {
  background: #d1fae5;
  color: #065f46;
}

.status-badge--rejected {
  background: #fee2e2;
  color: #991b1b;
}

.draft-banner {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  padding: var(--space-2) var(--space-3);
  max-width: 48rem;
  margin: 0 auto var(--space-3);
  font-size: 0.9rem;
  color: var(--color-muted);
}

.revision-banner {
  background: #fee2e2;
  border: 1px solid #fca5a5;
  border-radius: var(--radius);
  padding: var(--space-3);
  color: #991b1b;
  max-width: 36rem;
  margin: var(--space-4) auto 0;
}

.revision-banner a {
  color: inherit;
  font-weight: 700;
}

.form-actions {
  display: flex;
  gap: var(--space-2);
  justify-content: flex-end;
}

.review-actions {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-3);
  flex-wrap: wrap;
}

.review-actions form {
  display: inline-flex;
  gap: var(--space-2);
  align-items: center;
}

.review-actions textarea {
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.5rem 0.65rem;
  resize: vertical;
  width: 16rem;
}

/* Animated calendar date picker (public/js/date-picker.js) */
.date-picker {
  position: relative;
  display: flex;
  gap: var(--space-2);
}

.date-picker__input {
  flex: 1 1 auto;
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.6rem 0.75rem;
  cursor: pointer;
}

.date-picker__toggle {
  flex: 0 0 auto;
  font-size: 1.1rem;
  line-height: 1;
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.5rem 0.65rem;
  cursor: pointer;
}

/* ---------------------------------------------------------------------------
   Pickers are centered modals (2026-08-09, administration request: "a modal
   that is center of the viewport, both on mobile and desktop").

   Applies to all three pickers, not just the date one. They sit side by side in
   the same filter row in four views (projects/show, portal/project,
   timesheets/index put date next to month; daily-reports/edit puts date next to
   time), so making only one a modal would leave a centered dialog opening from
   one field and an anchored dropdown from the field beside it.

   Beyond the request, position:fixed makes them immune to ancestor clipping.
   That retires a real bug class: .detail-heading's overflow:hidden previously
   clipped the date panel so its month header showed and the entire day grid did
   not, which read as a broken picker rather than a clipped one.

   z-index 1001 sits one above .picker-backdrop (1000), which is the same layer
   .confirm-dialog__backdrop uses — deliberately matching the app's existing
   modal rather than inventing a second stacking convention.

   max-height + overflow-y matter on short viewports: a phone in landscape is
   ~375px tall, which is less than these panels need, and a centered fixed
   element cannot be scrolled to by the page.
   --------------------------------------------------------------------------- */
.picker-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  background: rgba(24, 38, 52, 0.55);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

.date-picker__panel {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 1001;
  max-height: calc(100dvh - 2rem);
  overflow-y: auto;
  /* Widened from 17.5rem so seven 44px day cells actually fit — see the
     .date-picker__day comment below. Deliberately sized in px, not rem: this
     file's `html { font-size: clamp(14px, ..., 16px) }` responsive root
     shrinks every rem on exactly the narrow viewports where the 44px touch
     floor matters most (22rem resolved to 317px at 390px wide, giving 42px
     cells). 7*44 + 6*2 gaps + ~29px padding ~= 350px. max-width keeps it
     inside a 320px viewport, degrading to slightly-under-44 rather than
     overflowing (this app has a standing zero-horizontal-scroll rule). */
  width: 350px;
  max-width: calc(100vw - 1.5rem);
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 4px 10px rgba(24, 38, 52, 0.1), 0 1px 3px rgba(24, 38, 52, 0.08);
  /* Horizontal padding trimmed (vertical unchanged) to hand the saved width
     back to the day grid — with var(--space-3) on both sides the seven columns
     came out at 41.8px, just under the touch floor this widening exists to
     reach. */
  padding: var(--space-3) var(--space-2);
  opacity: 0;
  /* The translate(-50%, -50%) IS the centering, so it has to be present in both
     states — the scale is the only part that animates. An .is-open rule of
     `transform: none` (what this used to be) would throw the panel to the
     bottom-right corner the moment it opened. */
  transform: translate(-50%, -50%) scale(0.96);
  pointer-events: none;
  transition: opacity 0.16s ease, transform 0.16s ease;
}

.date-picker__panel.is-open {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
  pointer-events: auto;
}

.date-picker__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-2);
  font-weight: 700;
}

.date-picker__nav {
  position: relative;
  background: none;
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  width: 1.75rem;
  height: 1.75rem;
  line-height: 1;
  font-size: 1rem;
  cursor: pointer;
  color: var(--color-text);
}

/* The visible 28px button stays compact (a full 44px box would look
   oversized in a tight calendar-header row), but the actual tap target is
   invisibly extended to ~44px via this ::before, rather than the button
   itself growing — the standard technique for a small control in a dense
   layout that still needs a real mobile touch target. */
.date-picker__nav::before {
  content: "";
  position: absolute;
  inset: -8px;
}

.date-picker__nav:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.date-picker__grid-holder {
  position: relative;
  overflow: hidden;
}

.date-picker__grid {
  display: grid;
  /* minmax(44px, 1fr) rather than plain 1fr so the touch floor is enforced by
     the grid itself — the cells can grow with a wider panel but can never be
     squeezed below 44px by a narrower one (they'd overflow instead, which the
     panel's own max-width prevents from reaching the page). */
  grid-template-columns: repeat(7, minmax(44px, 1fr));
  gap: 2px;
  transition: transform 0.18s ease, opacity 0.18s ease;
}

.date-picker__grid.is-leaving-left {
  transform: translateX(-100%);
  opacity: 0;
  position: absolute;
  inset: 0;
}

.date-picker__grid.is-leaving-right {
  transform: translateX(100%);
  opacity: 0;
  position: absolute;
  inset: 0;
}

.date-picker__grid.is-entering-right {
  transform: translateX(100%);
  opacity: 0;
}

.date-picker__grid.is-entering-left {
  transform: translateX(-100%);
  opacity: 0;
}

.date-picker__weekday {
  text-align: center;
  font-size: 0.75rem;
  color: var(--color-muted);
  padding: 0.2rem 0;
}

/* 2026-08-02 touch-ergonomics fix. Measured on a real 390px viewport, day
   cells rendered ~29-32px square — well under the 44px floor (WCAG 2.5.8),
   on a grid whose ADJACENT targets are different calendar dates. Mis-tapping
   here sets the wrong report date, which is a compliance error rather than a
   cosmetic one.

   Deliberately NOT solved with the invisible `::before { inset: -Npx }` trick
   used elsewhere in this file for the prev/next arrows: those are isolated
   controls with empty space around them, whereas these cells are packed in a
   7-column grid, so an inset overlay overlaps its neighbours. Measured that
   directly — a probe 5px to the right of a day cell resolved to the NEXT day's
   button, meaning each target ends up silently offset from the circle the user
   is actually aiming at. That's worse than a small centred target, not better.

   Real fix: make the cells genuinely 44px by widening the panel enough for
   7 columns to fit at that size (7*44 + 6*2px gaps + 2*16px padding ~= 352px),
   capped to the viewport so it can't overflow a narrow phone. */
.date-picker__day {
  aspect-ratio: 1;
  min-height: 44px;
  border: none;
  background: none;
  border-radius: 50%;
  font: inherit;
  font-size: 0.9rem;
  color: var(--color-text);
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease, transform 0.1s ease;
}

.date-picker__day:hover {
  background: var(--field-bg);
  transform: scale(1.08);
}

.date-picker__day--muted {
  color: var(--color-muted);
  opacity: 0.55;
}

.date-picker__day--disabled {
  color: var(--color-muted);
  opacity: 0.35;
  cursor: not-allowed;
}

.date-picker__day--disabled:hover {
  background: none;
  transform: none;
}

.date-picker__day--today {
  box-shadow: inset 0 0 0 1px var(--color-accent-2);
  font-weight: 700;
}

.date-picker__day--selected {
  background: var(--color-accent);
  color: #fff;
  font-weight: 700;
}

.date-picker__day--selected:hover {
  background: var(--color-accent);
  transform: none;
}

/* Animated month picker (public/js/month-picker.js) — same visual language
   as the date picker above (fade/scale open, slide transition on
   navigation), kept as its own class set rather than sharing the
   .date-picker__* names since a month grid (12 rectangular buttons, no
   weekday header) isn't a drop-in reuse of the day-grid markup. */
.month-picker {
  position: relative;
  display: flex;
  gap: var(--space-2);
}

.month-picker__input {
  flex: 1 1 auto;
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.6rem 0.75rem;
  cursor: pointer;
}

.month-picker__toggle {
  flex: 0 0 auto;
  font-size: 1.1rem;
  line-height: 1;
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.5rem 0.65rem;
  cursor: pointer;
}

/* Centered modal — see the .date-picker__panel block above for the full
   reasoning; all three pickers share it. */
.month-picker__panel {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 1001;
  max-height: calc(100dvh - 2rem);
  overflow-y: auto;
  width: 15rem;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 4px 10px rgba(24, 38, 52, 0.1), 0 1px 3px rgba(24, 38, 52, 0.08);
  padding: var(--space-3);
  opacity: 0;
  /* translate(-50%, -50%) is the centering and must appear in BOTH states. */
  transform: translate(-50%, -50%) scale(0.96);
  pointer-events: none;
  transition: opacity 0.16s ease, transform 0.16s ease;
}

.month-picker__panel.is-open {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
  pointer-events: auto;
}

.month-picker__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--space-2);
  font-weight: 700;
}

.month-picker__nav {
  position: relative;
  background: none;
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  width: 1.75rem;
  height: 1.75rem;
  line-height: 1;
  font-size: 1rem;
  cursor: pointer;
  color: var(--color-text);
}

/* Same invisible touch-target extension as .date-picker__nav above. */
.month-picker__nav::before {
  content: "";
  position: absolute;
  inset: -8px;
}

.month-picker__nav:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.month-picker__grid-holder {
  position: relative;
  overflow: hidden;
}

.month-picker__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
  transition: transform 0.18s ease, opacity 0.18s ease;
}

.month-picker__grid.is-leaving-left {
  transform: translateX(-100%);
  opacity: 0;
  position: absolute;
  inset: 0;
}

.month-picker__grid.is-leaving-right {
  transform: translateX(100%);
  opacity: 0;
  position: absolute;
  inset: 0;
}

.month-picker__grid.is-entering-right {
  transform: translateX(100%);
  opacity: 0;
}

.month-picker__grid.is-entering-left {
  transform: translateX(-100%);
  opacity: 0;
}

.month-picker__month {
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  background: none;
  font: inherit;
  font-size: 0.85rem;
  color: var(--color-text);
  padding: 0.45rem 0;
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.month-picker__month:hover {
  border-color: var(--color-accent);
}

.month-picker__month--disabled {
  color: var(--color-muted);
  opacity: 0.4;
  cursor: not-allowed;
}

.month-picker__month--disabled:hover {
  border-color: var(--hairline);
}

.month-picker__month--today {
  box-shadow: inset 0 0 0 1px var(--color-accent-2);
  font-weight: 700;
}

.month-picker__month--selected {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
  font-weight: 700;
}

.month-picker__month--selected:hover {
  border-color: var(--color-accent);
}

.month-picker__footer {
  display: flex;
  justify-content: flex-end;
  margin-top: var(--space-2);
}

/* .link-button is deliberately zero-padding everywhere else (it's meant to
   read as inline text, e.g. mid-sentence "resend code") — but here it's a
   standalone toolbar action, not inline text, so it gets its own real touch
   target the same way the compact date/month-picker nav arrows do. */
.month-picker__footer .link-button {
  position: relative;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
}

/* Custom time picker (public/js/time-picker.js) — user explicitly didn't
   want the native <input type="time"> control. Same visual language as
   .date-picker (fade/scale-open panel) but its own class names, since a
   scrollable Hour/Minute/AM-PM column layout isn't a drop-in reuse of the
   day-grid markup. */
.time-picker {
  position: relative;
  display: flex;
  gap: var(--space-2);
}

.time-picker__input {
  flex: 1 1 auto;
  min-width: 0;
  font: inherit;
  color: var(--color-text);
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.6rem 0.75rem;
  cursor: pointer;
}

.time-picker__toggle,
.time-picker__clear {
  flex: 0 0 auto;
  font-size: 1.1rem;
  line-height: 1;
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.5rem 0.65rem;
  cursor: pointer;
  color: var(--color-text);
}

.time-picker__clear {
  font-size: 1.3rem;
  color: var(--color-muted);
}

/* 2026-08-03 redesign — see public/js/time-picker.js's header comment for
   the full reasoning. Summary: the previous panel used three scrolling
   columns, so only ~3 of 12 hours were visible at once and AM/PM was a
   two-item scroll list that read as a rendering bug. Everything is now
   visible at once in two 6x2 grids plus a real segmented AM/PM control,
   with a live preview and a one-tap "Now". */
/* Centered modal — see the .date-picker__panel block above for the full
   reasoning; all three pickers share it. */
.time-picker__panel {
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 1001;
  max-height: calc(100dvh - 2rem);
  overflow-y: auto;
  /* Wide enough for a 6-column grid to keep ~44px cells, but never wider
     than the viewport allows — these fields sit inside multi-column form
     grids on narrow screens. Deliberately px, not rem: the daily-report
     editor shrinks the root font-size on narrow screens
     (daily-report-overrides.css's `html { font-size: clamp(14px…) }`), which
     would shrink a rem-based panel and push the grid cells back under the
     44px touch target on exactly the smallest screens that need them most. */
  width: min(336px, calc(100vw - 2rem));
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: var(--radius);
  box-shadow: 0 10px 24px rgba(24, 38, 52, 0.13), 0 2px 6px rgba(24, 38, 52, 0.08);
  padding: var(--space-3);
  opacity: 0;
  /* translate(-50%, -50%) is the centering and must appear in BOTH states. */
  transform: translate(-50%, -50%) scale(0.96);
  pointer-events: none;
  transition: opacity 0.16s ease, transform 0.16s ease;
}

/* .time-picker__panel--flip-right removed 2026-08-09. It anchored the panel to
   the right edge when a left-anchored one would overflow — meaningless now the
   panel is a centered modal, and actively harmful: its `left: auto; right: 0`
   would have overridden the centering. The JS that applied it is gone too. */

.time-picker__panel.is-open {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
  pointer-events: auto;
}

/* Live preview of the time being composed. The old panel showed nothing
   until after Done, so a mis-tap on the hour column was only discoverable
   by closing the picker and re-reading the field. */
.time-picker__preview {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--hairline);
}

.time-picker__preview-value {
  font-size: 1.35rem;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
  color: var(--color-text);
}

/* The dominant real use of this control is "what time is it right now"
   (clock in/out, visitor arrival), so it gets a dedicated one-tap action
   rather than being reachable only by composing hour + minute + period. */
.time-picker__now {
  flex: 0 0 auto;
  min-height: 44px;
  padding: 0.35rem 0.95rem;
  border: 1px solid var(--color-accent);
  border-radius: 999px;
  background: none;
  font: inherit;
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--color-accent);
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease;
}

.time-picker__now:hover {
  background: var(--color-accent);
  color: #fff;
}

.time-picker__section + .time-picker__section {
  margin-top: var(--space-3);
}

.time-picker__legend {
  display: block;
  margin-bottom: var(--space-1);
  font-size: 0.7rem;
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-muted);
}

/* Every option visible at once — recognition rather than scroll-hunting,
   and a stationary target for each value instead of one that moves as the
   column scrolls. */
.time-picker__grid {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 4px;
}

.time-picker__option {
  min-height: 44px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: calc(var(--radius) / 1.5);
  background: var(--field-bg);
  font: inherit;
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  color: var(--color-text);
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.time-picker__option:hover {
  border-color: var(--color-accent);
}

.time-picker__option--selected {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
  font-weight: 700;
}

.time-picker__option--selected:hover {
  background: var(--color-accent);
}

/* AM/PM as a real two-option segmented control. Previously this was a
   scrolling column containing exactly two items, which rendered as one
   filled block with a second option clipped below it. */
.time-picker__period-group {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px;
  margin-top: var(--space-3);
}

.time-picker__period {
  min-height: 44px;
  border: 1px solid var(--hairline);
  border-radius: calc(var(--radius) / 1.5);
  background: var(--field-bg);
  font: inherit;
  font-size: 0.9rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--color-muted);
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.time-picker__period:hover {
  border-color: var(--color-accent);
}

.time-picker__period.time-picker__option--selected {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
}

.time-picker__actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--hairline);
}

.time-picker__cancel,
.time-picker__done {
  min-height: 44px;
  border-radius: calc(var(--radius) / 1.5);
  padding: 0.45rem 1.15rem;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}

.time-picker__cancel {
  border: 1px solid var(--hairline);
  background: none;
  color: var(--color-muted);
}

.time-picker__cancel:hover {
  border-color: var(--color-accent);
  color: var(--color-accent);
}

.time-picker__done {
  border: 1px solid var(--color-accent);
  background: var(--color-accent);
  color: #fff;
}

.whitespace-pre-line {
  white-space: pre-line;
}

/* ==========================================================================
   Cross-family content primitives (2026-08-09)

   Both classes below were used in markup on FOUR pages each and defined in no
   stylesheet at all, so they rendered as ordinary body text. They live here,
   in the one sheet head.ejs loads on every page, rather than being copied into
   dashboard-overrides.css AND daily-report-overrides.css — that duplication is
   the same shape as the .mt-3/.mb-3 problem (three utilities, six identical
   copies) and it is how a class ends up defined for one page family and
   silently missing for another.

   Every reference-sheet token is given a literal fallback. --text-secondary,
   --border, --surface-muted and --alder-navy are all declared in
   daily-report-reference.css's :root, which four of the seven page families do
   not load; without the fallbacks these rules would silently lose their colour
   on exactly the plain pages that need them most. Same discipline as
   fields.css's .secondary-action:hover.
   ========================================================================== */

/* "Nothing here yet" / "that report is no longer available" copy. Must read as
   secondary, or the user cannot tell an empty state from an instruction.
   Verified before placing this here: there is no `.detail-card p` or
   `.detail-card__body p` rule anywhere in the project, so this class at
   (0,1,0) is not out-specified in either context it is actually used in. */
/* --neutral-text (#53676e). This rule originally used it INSTEAD of
   --text-secondary, because --text-secondary was #687b82 — 4.43:1 on white and
   4.10:1 on the page ground, under the 4.5:1 WCAG AA floor for normal text —
   and there was no reason to bake a known-failing value into a brand-new rule.
   At the time that left .empty-note deliberately darker than the metadata
   beside it, which was an accepted inconsistency.

   That inconsistency is now gone: --text-secondary was itself corrected to
   #53676e app-wide (see the override in fields.css's :root and the six
   background measurements behind it), so this token and that one now resolve
   to the SAME colour. The var() is kept rather than switched to
   var(--text-secondary) only because --neutral-text is defined by the
   reference stylesheet and this rule must also work on the four page families
   that never load it — hence the literal fallback. */
.empty-note {
  margin: 0;
  color: var(--neutral-text, #53676e);
  font-size: 0.95rem;
}

/* The reason an author gives for wanting an approved report reopened — the
   text a Regional Director reads to make the decision. Rendered as a
   <blockquote>, which arrives with a UA `margin-inline-start: 40px`; on the
   queue page that 40px stacked on top of the list's own 40px indent, pushing
   the decision text 80px in on a 375px screen. Resetting the margin is the
   load-bearing part of this rule, not the tint. */
.reopen-reason {
  margin: 12px 0;
  padding: 10px 14px;
  border-left: 3px solid var(--alder-navy, #003c55);
  border-radius: 0 8px 8px 0;
  background: var(--surface-muted, #f7faf9);
  color: var(--text-primary, #12313d);
  font-size: 0.95rem;
}

/* ==========================================================================
   The rest of the reopen panel — views/partials/reopen-panel.ejs

   HERE, not in a per-page override file, and that placement is the fix
   (2026-08-10, user-reported: "there is no space between approve and reopen,
   the note and decline button").

   These rules used to live in daily-report-overrides.css. That was fine while
   only the daily and weekly editors included the panel — both load that
   sheet. The moment the timesheet editor started including it too, they
   silently stopped applying there: it loads timesheet-overrides.css instead.
   The decide form lost its margin, .reopen-decide__actions fell back to
   `display: block`, and the two buttons ended up separated by nothing but
   collapsed HTML whitespace — 0px between the note and "Approve and reopen",
   4.4px between the two opposite decisions.

   That is this codebase's most-repeated bug: a class defined in an override
   file the page does not load. Copying them into a third override file would
   have fixed the symptom and set up the same failure for the fourth page. The
   panel is shared, so its styling belongs somewhere shared — style.css is
   loaded by head.ejs on every page, and .reopen-reason above was already here
   for exactly that reason.

   Reference-sheet tokens carry literal fallbacks (see .empty-note's note
   above) because this file must also work on pages that never load that sheet.
   ========================================================================== */

/* Groups the request (who asked, when, and their reason) so it reads as one
   record before the decision controls, rather than as loose paragraphs. */
.reopen-pending {
  border-left: 3px solid var(--alder-navy, #003c55);
  padding-left: 14px;
}

.reopen-pending > p:first-child {
  margin-top: 0;
}

.reopen-decide {
  margin-top: 16px;
}

/* Holds two submit buttons in ONE form, differing only by value: "Approve and
   reopen" (primary) and "Decline" (secondary) — two opposite, consequential
   decisions that need a reliable gap between their tap targets. Layout matches
   .submitted-report__review-actions, this app's already-field-tested
   approve/reject pair, so the reopen decision behaves like the one the same
   user already knows. */
.reopen-decide__actions {
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 16px;
}

/* Matches that same pair's mobile rule: at phone width both decisions become
   full-width, which removes any chance of a mis-tap between "approve" and
   "decline" by making them physically separate rows. 620px is the reference
   sheet's own dominant breakpoint rather than a new one. */
@media (max-width: 620px) {
  .reopen-decide__actions,
  .reopen-decide__actions .primary-action,
  .reopen-decide__actions .secondary-action {
    width: 100%;
  }
}

/* Visually hidden but still read by screen readers. Used where a visual
   affordance carries meaning that isn't in the text — e.g. the daily-report
   entry cards show their position as a small numbered badge (aria-hidden,
   since "1" alone is meaningless read aloud) and put the real "Work item 1:"
   prefix here instead. Not `display: none`/`visibility: hidden`, which would
   remove it from the accessibility tree too. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.legal-page {
  max-width: 42rem;
  margin: 0 auto;
  text-align: left;
}

.legal-page h2 {
  margin-top: var(--space-4);
}

table {
  width: 100%;
  border-collapse: collapse;
  text-align: left;
}

th,
td {
  padding: var(--space-2);
  border-bottom: 1px solid var(--hairline);
  font-size: 0.9rem;
}

/* ==========================================================================
   .breadcrumb / .form-notice for the plain page family (2026-08-09)

   Both are used by admin/role-changes.ejs, which loads only style.css +
   fields.css — while .breadcrumb lives in six override files and .form-notice
   in the reference sheet. That page matched neither, so its breadcrumb
   rendered as an ordinary sentence in body colour (no separator styling, no
   muted treatment) and its "showing the most recent N" notice was
   indistinguishable from the paragraph above it. A truncation warning the user
   cannot see is worse than none: it produces false confidence that the list is
   complete.

   Defining them HERE rather than adding a seventh copy to an override file is
   deliberate — style.css is the one sheet head.ejs loads everywhere, so this
   also covers the other plain pages (legal, errors, the four remaining admin
   screens) if any of them gain a breadcrumb later.

   This CANNOT affect the 30 reference-styled pages. Both selectors are
   (0,1,0), and the reference sheet plus every override file load AFTER
   style.css, so their own definitions win there by source order. Verified:
   the values below are copied from the reference sheet's own rules, so even if
   the cascade ever changed, the two would agree rather than diverge.
   ========================================================================== */
.breadcrumb {
  display: flex;
  align-items: center;
  gap: 9px;
  margin-bottom: 28px;
  color: var(--neutral-text, #53676e);
  font-size: 0.86rem;
  font-weight: 700;
}

.form-notice {
  display: flex;
  flex-direction: column;
  gap: 5px;
  margin-bottom: 26px;
  border: 1px solid transparent;
  border-radius: 12px;
  padding: 15px 17px;
  font-size: 0.9rem;
  line-height: 1.5;
}

.form-notice strong {
  font-weight: 850;
}

/* The variants are what make a notice VISIBLE — the base rule above
   deliberately has a transparent border and no background, matching the
   reference sheet, because a bare .form-notice is only ever a layout wrapper
   there. Ported so a plain page can state severity rather than relying on
   colour it doesn't have. */
.form-notice--warning {
  border-color: rgba(122, 85, 16, 0.25);
  background: var(--warning-background, #fff4d8);
  color: var(--warning-text, #7a5510);
}

.form-notice--error {
  border-color: rgba(167, 25, 32, 0.24);
  background: #fff0f0;
  color: #8f171c;
}

.form-notice--success {
  border-color: rgba(21, 94, 84, 0.22);
  background: var(--success-background, #e8f4ef);
  color: var(--success-text, #17604e);
}

/* Responsive stacked-row tables for the still-plain admin pages (Security,
   Deleted Projects, Disabled Users, Privacy Requests) — this app has an
   explicit "zero horizontal scroll anywhere" requirement (established for
   the daily-report editor's resource tables, see daily-report-overrides.css),
   so a raw <table> doesn't get an overflow-x:auto escape hatch here; every
   row collapses into a label/value stack below this breakpoint instead,
   mirroring the same technique .daily-resource-table already uses in
   daily-report-reference.css. Apply class="admin-table" to the <table> and
   a matching data-label="..." to each <td> — skip data-label on a
   purely-action cell (e.g. the trailing empty <th></th> column) so no
   blank label renders there. */

/* 2026-08-09: the stacked layout below was correct and working (thead is
   visually hidden, rows collapse to label/value pairs) and the page STILL
   scrolled horizontally. Measured on /admin/privacy-requests at 375px:
   112.8px of overflow, propagating from table.admin-table (scrollWidth 464 vs
   clientWidth 327) all the way up to <html>, on a page family whose own
   comment above promises "zero horizontal scroll anywhere".

   Cause, confirmed by experiment rather than inspection: below the breakpoint
   each td becomes `display: flex` with a `::before` label at `flex: 0 0 42%`.
   A flex item will not shrink below its min-content width, so a single
   unbreakable token — a real address like
   someone.with.a.fairly.long.address@contractor-company.com, or a long
   free-text reason with no spaces — forces the row wider than the viewport.
   Replacing just that one cell's text with "b@c.com" dropped the overflow to
   zero, which is what identified the token rather than the layout.

   Isolated which half of the candidate fix actually does the work:
     overflow-wrap: anywhere  ->  overflow 0     (this alone is sufficient)
     min-width: 0             ->  overflow 112.8 (no effect on its own)
   So this is one declaration, not two. Applied to the BASE rule rather than
   inside the media query because the same unbreakable token overflows a
   desktop table cell too, now that .page is width-constrained.

   Fixes all four .admin-table pages at once. */
.admin-table td,
.admin-table th {
  overflow-wrap: anywhere;
}

@media (max-width: 640px) {
  .admin-table thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
    padding: 0;
    margin: -1px;
  }

  .admin-table,
  .admin-table tbody,
  .admin-table tr,
  .admin-table td {
    display: block;
    width: 100%;
  }

  .admin-table tr {
    padding: var(--space-3) 0;
    border-bottom: 1px solid var(--hairline);
  }

  .admin-table td {
    border-bottom: none;
    padding: var(--space-1) 0;
    display: flex;
    gap: var(--space-2);
    font-size: 0.95rem;
  }

  .admin-table td[data-label]::before {
    content: attr(data-label);
    flex: 0 0 42%;
    font-weight: 600;
    color: var(--color-muted);
  }

  .admin-table td:not([data-label]) {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-2);
    margin-top: var(--space-2);
  }
}

.site-footer {
  margin-top: var(--space-4);
  display: flex;
  gap: var(--space-3);
  font-size: 0.85rem;
  color: var(--color-muted);
}

.site-footer a {
  color: inherit;
}

.skeleton {
  background: linear-gradient(90deg, var(--hairline) 25%, #eef1f5 37%, var(--hairline) 63%);
  background-size: 400% 100%;
  animation: skeleton-loading 1.4s ease infinite;
  border-radius: var(--radius);
}

@keyframes skeleton-loading {
  0% {
    background-position: 100% 50%;
  }
  100% {
    background-position: 0 50%;
  }
}

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

/* Toast notifications — fixed-position, dismissible pop-ups for
   transient/actionable errors (e.g. daily-report upload failures,
   submission-blocking validation) that shouldn't push page content down
   the way an inline .form-notice banner does. Persistent/contextual
   notices (read-only-draft status, "this is a corrected revision") stay
   as inline banners; only real errors use this. */
.toast-stack {
  position: fixed;
  top: var(--space-3);
  left: 50%;
  transform: translateX(-50%);
  z-index: 1000;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  width: min(92vw, 480px);
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  border-radius: var(--radius);
  border: 1px solid transparent;
  padding: 0.9rem 1rem;
  box-shadow: 0 12px 32px rgba(24, 38, 52, 0.18);
  pointer-events: auto;
  animation: toast-in 0.25s ease;
}

.toast--error {
  border-color: rgba(220, 38, 38, 0.28);
  background: #fff0f0;
  color: #8f171c;
}

/* 2026-08-02 HCI fix: until now `toast--error` was the ONLY variant, and
   AlderToast.show() hardcoded it — so genuinely positive, expected messages
   ("Saved offline — this will sync automatically once you're back online")
   rendered in alarm-red with an assertive role="alert" screen-reader
   interruption. That actively undermined confidence in the offline system,
   which is exactly the feature that most depends on the user trusting it.
   Success/info now have their own treatment and use role="status" instead
   (see toast.js) so assistive tech announces them politely, not urgently. */
.toast--success {
  border-color: rgba(13, 148, 136, 0.32);
  background: #effcf9;
  color: #0f4c46;
}

.toast--info {
  border-color: var(--hairline);
  background: var(--color-surface);
  color: var(--color-text);
}

.toast--warning {
  border-color: rgba(180, 120, 10, 0.32);
  background: #fffaf0;
  color: #7c4a03;
}

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

.toast__body strong {
  display: block;
  margin-bottom: 0.15rem;
}

.toast__body span {
  display: block;
}

.toast__dismiss {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  margin: -0.5rem -0.5rem -0.5rem 0;
  border: 0;
  background: transparent;
  color: inherit;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
  border-radius: 0.5rem;
}

.toast__dismiss:hover,
.toast__dismiss:focus-visible {
  background: rgba(0, 0, 0, 0.08);
}

.toast.is-dismissed {
  animation: toast-out 0.18s ease forwards;
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(-12px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

@keyframes toast-out {
  from {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
  to {
    opacity: 0;
    transform: translateY(-8px) scale(0.98);
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: none;
  }
  .toast.is-dismissed {
    animation: none;
    display: none;
  }
}

/* Real upload progress (XHR-driven, see attachment-upload.js) — replaces
   the plain "the button is disabled" feedback with an actual byte-progress
   bar, since attachment uploads (photos/video/PDF, scanned for malware
   before storage) can take several seconds on a mobile connection. */
.upload-progress {
  margin-top: var(--space-2);
}

.upload-progress__track {
  height: 10px;
  border-radius: 999px;
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  overflow: hidden;
}

.upload-progress__fill {
  height: 100%;
  width: 0%;
  background: var(--color-accent);
  border-radius: 999px;
  transition: width 0.15s ease;
}

.upload-progress__label {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: var(--color-muted);
}

/* The server-side wait (2026-08-12).

   The bar can only advance while request-body bytes are in flight. After the
   last byte the server still scans, hashes, stores and (for photos) queues a
   rendition — measured at ~1.65s per photo, 57.3s of a real 116.3s 33-photo
   upload. A motionless bar for seventeen seconds at a stretch is what the
   "looks stuck" complaint was about.

   The stripe moves while the FILL stays where it genuinely is: the position is
   still honest, the motion just says the request has not stalled. Faking
   forward progress here would be worse than the freeze — it would reach 100%
   before the files were safe. */
.upload-progress.is-working .upload-progress__fill {
  background-image: linear-gradient(
    115deg,
    rgba(255, 255, 255, 0.32) 25%,
    transparent 25%,
    transparent 50%,
    rgba(255, 255, 255, 0.32) 50%,
    rgba(255, 255, 255, 0.32) 75%,
    transparent 75%
  );
  background-size: 1.6rem 1.6rem;
  animation: upload-progress-stripes 0.9s linear infinite;
}

@keyframes upload-progress-stripes {
  from {
    background-position: 0 0;
  }
  to {
    background-position: 1.6rem 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* No animation, so the phase has to be legible without it — the label still
     reads "Scanning and saving 15 of 39…", which is the actual information. */
  .upload-progress.is-working .upload-progress__fill {
    animation: none;
  }
}

/* Files the server refused (2026-08-12).

   An upload can partially succeed now, so this has to survive the reload that
   follows one — a toast is gone in seconds and an RPR who has left the site
   cannot re-take a photo nobody told them about. Styled as a warning rather
   than an error: the upload as a whole worked, these specific files did not. */
.upload-failures {
  margin-top: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--color-danger);
  border-radius: 12px;
  background: #fdf4f4;
}

.upload-failures__list {
  margin: 0.5rem 0 0;
  padding-left: 1.1rem;
}

.upload-failures__list li {
  margin-bottom: 0.35rem;
}

.upload-failures__name {
  font-weight: 600;
  overflow-wrap: anywhere; /* real camera filenames are long and unbreakable */
}

.upload-failures__reason {
  display: block;
  font-size: 0.85rem;
  color: var(--color-muted);
}

.upload-failures__note {
  margin: 0.6rem 0 0;
  font-size: 0.85rem;
  color: var(--color-muted);
}

/* Offline sync progress ("Uploading 12 of 39…", see
   public/js/offline-sync-progress.js).

   Lives in style.css specifically because BOTH surfaces that show it load
   this file and only this file in common: the daily-report editor loads
   style.css + daily-report-reference.css + daily-report-overrides.css +
   fields.css, and offline-draft.html loads style.css +
   daily-report-reference.css + offline-draft-overrides.css + fields.css.
   daily-report-reference.css is the ported vendor sheet and is never
   hand-edited, and putting the rules in the two per-page override files
   would be the same duplicate-definition trap that produced the .mt-3 bug.

   Deliberately NOT reusing .upload-progress above: same visual language on
   purpose, but that component belongs to a single XHR file upload and this
   one to a whole queued batch. Sharing the classes would silently couple
   two unrelated features' styling — the next tweak to one would land on
   the other with nothing to warn either author. */
/* ---- the modal itself -----------------------------------------------------

   Centred in the viewport (user-specified, 2026-08-14). position:fixed with
   inset:0 and flex centring, so it is centred against the VIEWPORT rather than
   against whichever container happened to mount it — the inline row lives
   inside a .detail-card, and centring against that would put the dialog
   wherever the card happened to be scrolled to.

   z-index sits above the save pill (850) and the connectivity banner (900),
   because unlike those this one is a dialog that has deliberately taken over
   the screen. */
.sync-modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-4);
}

/* The [hidden] attribute is honoured explicitly. `display:flex` above would
   otherwise beat the user-agent's own [hidden]{display:none} at equal
   specificity — the exact bug this codebase has now hit and patched a dozen
   times, most recently on .notification-dropdown. There is also a global
   backstop in this file; this rule is the local, obvious one. */
.sync-modal[hidden] {
  display: none;
}

.sync-modal__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(24, 38, 52, 0.55);
  opacity: 0;
  transition: opacity 0.18s ease;
}

.sync-modal.is-open .sync-modal__backdrop {
  opacity: 1;
}

.sync-modal__card {
  position: relative;
  width: min(30rem, 100%);
  max-height: calc(100vh - 2 * var(--space-4));
  overflow-y: auto;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  box-shadow: 0 24px 60px rgba(15, 30, 45, 0.28);
  /* --space-5 does not exist in this codebase (the scale stops at --space-4).
     An undefined custom property with no fallback makes the WHOLE declaration
     invalid at computed-value time, so this silently resolved to padding: 0 and
     the content sat hard against the card edges — reported visually before the
     cause was found. Only defined tokens here, and a little extra on top so the
     title has room to breathe. */
  padding: calc(var(--space-4) + var(--space-2)) var(--space-4) var(--space-4);
  opacity: 0;
  transform: translateY(10px) scale(0.97);
  transition:
    opacity 0.2s ease,
    transform 0.2s ease;
}

.sync-modal.is-open .sync-modal__card {
  opacity: 1;
  transform: translateY(0) scale(1);
}

.sync-modal__title {
  margin: 0 0 0.25rem;
  font-size: 1.15rem;
  color: var(--color-text);
}

.sync-modal__sub {
  margin: 0 0 var(--space-4);
  font-size: 0.9rem;
  color: var(--color-muted);
}

/* ---- the stage list ---- */
.sync-stages {
  list-style: none;
  margin: 0 0 var(--space-4);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

.sync-stage {
  display: grid;
  grid-template-columns: 22px 1fr;
  gap: 0 0.7rem;
  align-items: start;
}

/* The marker encodes state as SHAPE as well as colour: a ring while waiting,
   a filled dot while running, a tick once done. Colour alone would be the
   only signal otherwise, which fails for a colour-blind reader and in forced
   -colours mode. */
.sync-stage__mark {
  width: 18px;
  height: 18px;
  margin-top: 2px;
  border-radius: 999px;
  border: 2px solid var(--hairline);
  background: transparent;
  position: relative;
  flex: none;
}

/* A ring with a centre dot, not a solid fill. The brand accent is red, and a
   solid red dot sitting directly beneath two green ticks reads as "this step
   FAILED" rather than "this step is running" — spotted on a render, not in
   review. The radio-style marker keeps three genuinely distinct SHAPES (empty
   ring waiting, ring-with-dot running, tick done) so state survives without
   colour, while no longer looking like an error. */
.sync-stage[data-state="active"] .sync-stage__mark {
  border-color: var(--color-accent);
  background: transparent;
}

.sync-stage[data-state="active"] .sync-stage__mark::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 999px;
  background: var(--color-accent);
  animation: sync-stage-pulse 1.4s ease-in-out infinite;
}

@keyframes sync-stage-pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.45;
  }
}

.sync-stage[data-state="done"] .sync-stage__mark {
  border-color: var(--color-success-text, #157a4a);
  background: var(--color-success-text, #157a4a);
}

.sync-stage[data-state="done"] .sync-stage__mark::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}

.sync-stage__label {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--color-text);
}

.sync-stage[data-state="waiting"] .sync-stage__label {
  color: var(--color-muted);
  font-weight: 500;
}

.sync-stage__track {
  height: 8px;
  margin: 0.4rem 0 0;
  border-radius: 999px;
  background: var(--field-bg);
  border: 1px solid var(--hairline);
  overflow: hidden;
}

/* Only the running stage draws a bar. A finished stage says "Done" and a
   waiting one says how much is coming — a full bar on every completed row
   would be three competing 100% bars saying nothing. */
.sync-stage[data-state="done"] .sync-stage__track,
.sync-stage[data-state="waiting"] .sync-stage__track {
  display: none;
}

.sync-stage__fill {
  height: 100%;
  width: 0%;
  background: var(--color-accent);
  border-radius: 999px;
  /* 0.15s, matching .upload-progress__fill: this receives byte-level progress
     during an attachment upload, so a slower ease lags visibly behind the
     real value. */
  transition: width 0.15s ease;
}

/* Bytes are out; the server is virus-scanning, storing to R2 and processing
   the image. Byte progress has nothing left to report, so the stripe carries
   "still working" instead — the same treatment, and the same keyframes, the
   online uploader uses for the identical phase.

   Deliberately NOT faked forward motion: the bar stays where the bytes
   actually got to. Advancing it here would claim the photo was saved before
   it was. */
.sync-stage__fill.is-working {
  background-image: linear-gradient(
    115deg,
    rgba(255, 255, 255, 0.32) 25%,
    transparent 25%,
    transparent 50%,
    rgba(255, 255, 255, 0.32) 50%,
    rgba(255, 255, 255, 0.32) 75%,
    transparent 75%
  );
  background-size: 1.6rem 1.6rem;
  animation: upload-progress-stripes 0.9s linear infinite;
}

.sync-stage__count {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: var(--color-muted);
  font-variant-numeric: tabular-nums;
}

.sync-modal__eta {
  margin: 0 0 var(--space-4);
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--color-text);
}

.sync-modal__eta[hidden] {
  display: none;
}

.sync-modal__dismiss {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 0.6rem 1rem;
  border-radius: 10px;
  border: 1px solid var(--color-accent);
  background: transparent;
  color: var(--color-accent);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.sync-modal__dismiss:hover {
  background: rgba(156, 34, 34, 0.06);
}

.sync-modal__note {
  margin: var(--space-3) 0 0;
  font-size: 0.83rem;
  color: var(--color-muted);
  text-align: center;
}

/* ---- the dismissed state: a compact row at the caller's own mount point ----
   Margins, not padding: both hosts are .detail-card, which already supplies
   its own padding. */
.sync-inline {
  margin: var(--space-3) 0;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

.sync-inline:last-child {
  margin-bottom: 0;
}

.sync-inline[hidden] {
  display: none;
}

.sync-inline__spinner {
  width: 14px;
  height: 14px;
  border-radius: 999px;
  border: 2px solid var(--hairline);
  border-top-color: var(--color-accent);
  animation: btn-spin 0.7s linear infinite;
  flex: none;
}

.sync-inline__text {
  font-size: 0.87rem;
  font-weight: 600;
  color: var(--color-muted);
  flex: 1 1 10rem;
  min-width: 0;
}

.sync-inline__show {
  min-height: 44px;
  padding: 0.35rem 0.8rem;
  border-radius: 8px;
  border: 1px solid var(--hairline);
  background: var(--color-surface);
  color: var(--color-accent);
  font: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}

@media (prefers-reduced-motion: reduce) {
  .sync-stage__fill,
  .sync-modal__card,
  .sync-modal__backdrop {
    transition: none;
  }
  /* No stripe, so the phase has to be legible without it — the count still
     reads "Scanning and saving 13 of 33", which is the actual information.
     The stage marker keeps its shape; only the pulse stops. */
  .sync-stage__fill.is-working,
  .sync-inline__spinner,
  .sync-stage[data-state="active"] .sync-stage__mark::after {
    animation: none;
  }
}

/* ---------------------------------------------------------------------------
   Per-report editing lock (2026-08-14) — see public/js/report-sync-lock.js.
   A report whose own queued work is still uploading is held read-only, because
   an edit made while those writes are landing is exactly how a lockVersion
   conflict is manufactured. Other reports are untouched. */
.sync-lock-banner {
  margin: var(--space-3) 0;
  padding: 0.9rem 1.05rem;
  border-radius: 12px;
  border: 1px solid var(--hairline);
  border-left: 3px solid var(--color-accent);
  background: var(--field-bg);
}

.sync-lock-banner__title {
  margin: 0 0 0.2rem;
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--color-text);
}

.sync-lock-banner__detail {
  margin: 0;
  font-size: 0.88rem;
  line-height: 1.5;
  color: var(--color-muted);
}

/* Belt-and-braces behind the `inert` attribute the lock actually relies on.
   inert has been supported everywhere that matters since 2022, but a browser
   that ignores it would silently leave the forms live — and silently is the
   problem, since the whole point is preventing a write. This makes the failure
   visible (greyed and unclickable) rather than invisible.

   Deliberately NOT `display:none` or an overlay: the report stays readable
   while it uploads, which is a reasonable thing to want. Read-only, not
   blind. */
[data-sync-locked] form[inert],
[data-sync-locked] form[inert] * {
  pointer-events: none;
}

[data-sync-locked] form[inert] {
  opacity: 0.55;
}

/* ---------------------------------------------------------------------------
   Ambient connectivity indicator (2026-08-02 HCI fix) — see
   public/js/connectivity-status.js for the full reasoning. Bottom-anchored so
   it never competes with the sticky top header or the top-centred toast stack,
   and pointer-events:none so it can never intercept a tap meant for content
   underneath it (it's purely informational — there's nothing to click).
   --------------------------------------------------------------------------- */
.connectivity-status {
  position: fixed;
  left: 50%;
  bottom: var(--space-3);
  transform: translateX(-50%);
  z-index: 900;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  max-width: min(92vw, 30rem);
  padding: 0.55rem 0.9rem;
  border-radius: 999px;
  border: 1px solid rgba(180, 120, 10, 0.35);
  background: #fffaf0;
  color: #7c4a03;
  font-size: 0.85rem;
  font-weight: 600;
  box-shadow: 0 10px 28px rgba(24, 38, 52, 0.16);
  pointer-events: none;
}

/* The native `hidden` attribute's implicit display:none loses to the display:flex
   above (author styles beat the UA stylesheet regardless of specificity) — this
   app has hit that exact trap before with .notification-dropdown and the
   daily-report editor's [data-when] elements. Force it back explicitly. */
.connectivity-status[hidden] {
  display: none;
}

.connectivity-status__dot {
  flex: 0 0 auto;
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 50%;
  background: #b4780a;
}

/* ---------------------------------------------------------------------------
   In-app confirmation dialog (2026-08-06). Replaces window.confirm() for
   data-confirm forms — see public/js/form-loading.js for why the native
   dialog had to go. Uses the app's own tokens so it matches everything else,
   and a high z-index so it sits above the sticky header and any toast.
   --------------------------------------------------------------------------- */
.confirm-dialog__backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: rgba(24, 38, 52, 0.55);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}

.confirm-dialog {
  width: 100%;
  max-width: 440px;
  background: var(--color-surface);
  border: 1px solid var(--hairline);
  border-radius: 16px;
  box-shadow: 0 20px 50px rgba(24, 38, 52, 0.28);
  padding: 24px;
}

.confirm-dialog__message {
  margin: 0 0 20px;
  color: var(--color-text);
  font-size: 1rem;
  line-height: 1.5;
}

.confirm-dialog__actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  flex-wrap: wrap;
}

/* On a narrow screen stack the buttons full-width so they stay a comfortable
   tap target rather than shrinking side by side. */
@media (max-width: 420px) {
  .confirm-dialog__actions {
    flex-direction: column-reverse;
  }
  .confirm-dialog__actions .btn {
    width: 100%;
  }
}

/* =====================================================================
   SAVE PILL — shared by the weekly AND daily report editors
   =====================================================================
   Moved here from weekly-report-overrides.css on 2026-08-12, unchanged,
   when the daily editor gained autosave. It is byte-for-byte the same
   component the weekly report already used — deliberately MOVED rather
   than copied, because a second copy is how two surfaces that should
   look identical quietly stop looking identical.

   It has to live in style.css specifically: that is the only stylesheet
   BOTH editors load (via partials/head.ejs). weekly-report-overrides.css
   is not loaded by the daily editor, so the markup would have rendered
   completely unstyled there — the recurring failure in this codebase of a
   class defined in a stylesheet the page never loads.
   ===================================================================== */
/* ---------------------------------------------------------------------
   Floating save indicator (2026-08-12)
   ---------------------------------------------------------------------
   Replaces a sticky full-width save bar that was rejected for being in
   the way — a permanent row over the content whether or not it had
   anything to say.

   This speaks when the state changes and recedes when it doesn't. Only a
   failure keeps it fully present and gives it something to press.

   Two rules here are load-bearing rather than cosmetic:

     - pointer-events stay OFF unless it is actionable, so a faded pill
       can never swallow a click meant for the page underneath. That is
       the exact failure mode this project's fixed-overlay history keeps
       producing.
     - .connectivity-status is also fixed to the bottom, centred, and on
       a narrow screen its pill spans nearly the full width. When both are
       up this one lifts clear of it instead of overlapping.
   --------------------------------------------------------------------- */
.save-pill {
  position: fixed;
  right: var(--space-3, 16px);
  bottom: var(--space-3, 16px);
  /* Under toasts/dialogs (1000) and under .connectivity-status (900):
     both carry more urgent messages than "saved". */
  z-index: 850;
  display: flex;
  align-items: center;
  gap: 8px;
  max-width: min(88vw, 22rem);
  padding: 8px 14px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface);
  box-shadow: 0 8px 24px rgba(24, 38, 52, 0.16);
  color: var(--neutral-text-muted);
  font-size: 0.82rem;
  font-weight: 600;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.35s ease, transform 0.25s ease;
}

.save-pill[hidden] {
  display: none;
}

.save-pill__dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--neutral-text-muted);
}

.save-pill__text {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Mid-save. */
.save-pill--saving .save-pill__dot {
  background: #c08a2e;
  animation: save-pill-pulse 1s ease-in-out infinite;
}
.save-pill--saving { color: #8a5f13; }

/* Saved, and on its way out of your attention. */
.save-pill--saved { color: #157a4a; }
.save-pill--saved .save-pill__dot { background: #157a4a; }

/* Typed but not yet sent — kept on this device only, nothing written to the
   report until the field is left. Distinct from --saving (amber, a request is
   actually in flight) and --saved (green, it landed on the server). */
.save-pill--device { color: #3d6d99; }
.save-pill--device .save-pill__dot { background: #3d6d99; }

/* Settled: still legible if you look for it, invisible if you don't. */
.save-pill--settled {
  opacity: 0.4;
  transform: translateY(2px);
}

/* The one state that must not be missed: stays put, and is clickable. */
.save-pill--failed {
  pointer-events: auto;
  opacity: 1;
  transform: none;
  border-color: var(--alder-red);
  background: #fdf1f1;
  color: var(--alder-red);
  font-weight: 700;
  white-space: normal;
}
.save-pill--failed .save-pill__dot { background: var(--alder-red); }
.save-pill--failed .save-pill__text {
  white-space: normal;
  overflow: visible;
}

.save-pill__retry {
  flex: none;
  padding: 5px 12px;
  min-height: 32px;
  border: 1px solid var(--alder-red);
  border-radius: 999px;
  background: var(--alder-red);
  color: #fff;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.save-pill__retry[hidden] { display: none; }
.save-pill__retry:hover { background: #8f151b; }

/* Lifted clear of .connectivity-status, which is fixed bottom-centre and
   on a phone is nearly full width. Applied only while offline. */
.save-pill--above-connectivity {
  bottom: calc(var(--space-3, 16px) + 3.4rem);
}

@keyframes save-pill-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

@media (prefers-reduced-motion: reduce) {
  .save-pill { transition: none; }
  .save-pill--saving .save-pill__dot { animation: none; }
}

@media (max-width: 480px) {
  .save-pill {
    /* 2026-08-14, user-reported: on a phone this was "just too long".
       Measured at 375px: a full-width band at 92% of the screen — left AND
       right both pinned — carrying a message that is usually two words, and
       sitting over the content the whole time it was up.

       Sized to its content instead, and anchored bottom-LEFT, diagonally
       opposite the progress pill which sits bottom-right one row up. The two
       can no longer approach each other at any message length, and the band
       stops covering the full width of whatever is underneath it.

       .save-pill__text is already nowrap + ellipsis, so a long message
       shortens rather than growing the pill — its height stays fixed, which
       is what keeps the clearance below the progress pill constant. */
    left: var(--space-3, 16px);
    right: auto;
    max-width: min(74vw, 20rem);
    justify-content: flex-start;
  }
}

/* ---------------------------------------------------------------------------
   Gallery tile loading state (2026-08-14)

   Customer report: a photo tile sits empty for a moment before the image
   arrives, which on a grid of 33 reads as broken rather than loading.

   Lives HERE, not in the two *-overrides.css files, because both galleries
   need it and style.css is the only stylesheet the portal and the editor both
   load — the same reasoning that put .save-pill here. It cannot go on
   .attachment-gallery__item's `background` either: both override files set
   that and both load later, so a same-specificity rule here would lose. The
   shimmer is therefore a ::before, which neither file touches.

   Everything is gated on [data-gallery-loading], set by attachment-gallery.js
   only once it is actually running. With no JS the tiles render exactly as
   they did before — visible immediately, no skeleton, no fade. That matters:
   an ungated `opacity: 0` would hide every photo permanently if the script
   failed to load.
   --------------------------------------------------------------------------- */

[data-gallery-loading] .attachment-gallery__item::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: linear-gradient(90deg, var(--hairline) 25%, #eef1f5 37%, var(--hairline) 63%);
  background-size: 400% 100%;
  animation: skeleton-loading 1.4s ease infinite;
}

/* `content: none` removes the pseudo-element outright rather than hiding it,
   so the animation genuinely stops instead of running forever under 33 opaque
   photos. */
[data-gallery-loading] .attachment-gallery__item.is-loaded::before {
  content: none;
}

/* The image must paint ABOVE the shimmer. ::before is absolutely positioned,
   so without this the skeleton would cover the photo it is meant to precede.
   The badge is positioned and later in the DOM, so it still sits on top. */
[data-gallery-loading] .attachment-gallery__thumb {
  position: relative;
  z-index: 1;
  opacity: 0;
  transition: opacity 0.25s ease;
}

[data-gallery-loading] .attachment-gallery__item.is-loaded .attachment-gallery__thumb {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  [data-gallery-loading] .attachment-gallery__item::before {
    animation: none;
  }
  [data-gallery-loading] .attachment-gallery__thumb {
    transition: none;
  }
}

/* ---------------------------------------------------------------------------
   Lightbox loading state (2026-08-14)

   Customer report: opening a photo shows nothing for a moment. The full-size
   rendition is ~677 KB — attachment-gallery.js already preloads the next and
   previous photos, so stepping feels instant, but the FIRST one opened has
   nothing warmed.

   In style.css for the same reason as the tile skeleton: .attachment-lightbox__*
   is defined identically in BOTH override files, and this is the only
   stylesheet the portal and the editor both load.

   Driven by .is-loading, which attachment-gallery.js adds before setting src
   and removes on load or error. With no JS there is no lightbox at all (the
   tile is a plain link to the image), so there is nothing to degrade.
   --------------------------------------------------------------------------- */

.attachment-lightbox__media.is-loading {
  position: relative;
}

.attachment-lightbox__media.is-loading::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border: 3px solid rgba(255, 255, 255, 0.28);
  border-top-color: #ffffff;
  border-radius: 50%;
  animation: btn-spin 0.7s linear infinite;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .attachment-lightbox__media.is-loading::after {
    animation: none;
    /* Still a visible marker that something is happening, just not spinning. */
    border-top-color: rgba(255, 255, 255, 0.7);
  }
}
