/*
 * Base styles the utility classes are a bad fit for.
 *
 * Form controls are the reason this file exists: there are several hundred
 * inputs across the templates, and putting five identical classes on each of
 * them is both noise and a guarantee that one gets missed. Everything here is
 * a default the markup can still override with a utility class.
 */

/*
 * The comment above says "a default the markup can still override with a
 * utility class". That was not true of the input rule below: its three :not()
 * clauses each contribute an attribute selector's specificity, scoring 0-3-1
 * against a utility class's 0-1-0. A public form asked to be light kept black
 * inputs and nothing in the markup could say otherwise.
 *
 * So the theme is decided here instead, from data-form-theme on <html>, which
 * head.php sets. Hub itself never sets it and stays dark.
 */
:root {
  /* Tells the browser to render native widgets — date pickers, scrollbars,
     the autofill overlay — in their dark variants. Without it a date field
     opens a white calendar in the middle of a dark page. */
  color-scheme: dark;
}

html[data-form-theme="light"] { color-scheme: light; }
html[data-form-theme="system"] { color-scheme: light dark; }

body {
  -webkit-font-smoothing: antialiased;
}

html:not([data-form-theme="light"]) input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
html:not([data-form-theme="light"]) select,
html:not([data-form-theme="light"]) textarea {
  background-color: #18181b;   /* zinc-900 */
  border-color: #3f3f46;       /* zinc-700 */
  color: #e4e4e7;              /* zinc-200 */
}

input::placeholder,
textarea::placeholder {
  /* Tracks zinc-500, which moved to #8d8d97 on 2026-10-07 for AA. These
     three hand-written copies all said "zinc-500" in a comment and then
     froze the old value, so the palette change left them behind — a
     placeholder is text somebody has to read before they type over it. */
  color: #8d8d97;              /* zinc-500 */
}

input:focus,
select:focus,
textarea:focus {
  outline: 2px solid transparent;
  outline-offset: 2px;
  border-color: #a78bfa;       /* violet-400, the Hub mark's own colour */
  box-shadow: 0 0 0 1px #a78bfa;
}

input:disabled,
select:disabled,
textarea:disabled {
  background-color: #27272a;   /* zinc-800 */
  color: #a1a1aa;              /* zinc-400 */
  cursor: not-allowed;
}

/* Checkboxes default to a light-grey box on dark; accent-color is the one
   property that restyles them without rebuilding the control. */
input[type="checkbox"],
input[type="radio"] {
  accent-color: #a78bfa;
}

/* Chrome paints autofilled fields a hard-coded pale yellow. The inset shadow
   is the only way to cover it that Chrome honours. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
  -webkit-text-fill-color: #e4e4e7;
  -webkit-box-shadow: 0 0 0 1000px #18181b inset;
  caret-color: #e4e4e7;
}

summary { cursor: pointer; }
summary::-webkit-details-marker { color: #8d8d97; }  /* zinc-500 */

/* The account menu's trigger is an avatar, not a disclosure row — the marker
   would sit beside it as a stray triangle. */
details[data-menu] > summary { list-style: none; }
details[data-menu] > summary::-webkit-details-marker { display: none; }
details[data-menu] > summary::marker { content: ''; }

::selection { background: #8b5cf6; color: #fafafa; }

/* Scrollbars, so a dark page does not get a bright grey gutter down its side. */
* { scrollbar-color: #3f3f46 transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
  background: #3f3f46;
  border: 2px solid transparent;
  background-clip: content-box;
  border-radius: 6px;
}
*::-webkit-scrollbar-thumb:hover { background: #52525b; background-clip: content-box; }

/* Printing a page of notes or an order of service should not spend a cartridge
   on the dark background. */
@media print {
  body { background: #fff !important; color: #18181b !important; }
  aside, header nav { display: none !important; }
}


/* ---------------------------------------------------------------------------
   Light controls, for a form pinned light or following a light device.

   The same :not() chain, so the specificity matches rather than fights: these
   win on being later in the file, which is the predictable half of the
   cascade. Scoped to the attribute, so Hub's own screens never see them.
   --------------------------------------------------------------------------- */
html[data-form-theme="light"] input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
html[data-form-theme="light"] select,
html[data-form-theme="light"] textarea {
  background-color: #ffffff;
  border-color: #d4d4d8;       /* zinc-300 */
  color: #18181b;              /* zinc-900 */
}

html[data-form-theme="light"] input::placeholder,
html[data-form-theme="light"] textarea::placeholder {
  color: #a1a1aa;              /* zinc-400 */
}

html[data-form-theme="light"] input:-webkit-autofill,
html[data-form-theme="light"] input:-webkit-autofill:hover,
html[data-form-theme="light"] input:-webkit-autofill:focus {
  -webkit-text-fill-color: #18181b;
  caret-color: #18181b;
}

@media (prefers-color-scheme: light) {
  html[data-form-theme="system"] input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
  html[data-form-theme="system"] select,
  html[data-form-theme="system"] textarea {
    background-color: #ffffff;
    border-color: #d4d4d8;
    color: #18181b;
  }
  html[data-form-theme="system"] input::placeholder,
  html[data-form-theme="system"] textarea::placeholder { color: #a1a1aa; }
}


/* ---------------------------------------------------------------------------
   The boot screen.

   Invisible until boot.js says otherwise, and it removes itself: the animation
   ends on opacity 0 with visibility hidden, so nothing has to run to take it
   away again. If the script never fires the screen simply never appears, which
   is the right way round for something decorative.
   --------------------------------------------------------------------------- */
#boot {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.25rem;
  background: #09090b;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

#boot[data-show] {
  visibility: visible;
  /* forwards: it stays hidden at the end rather than snapping back. */
  animation: boot-out 3000ms ease forwards;
}

#boot[data-show="still"] { animation-duration: 1600ms; }

/* The two marks, side by side, reading as "MDV Hub". */
.boot-marks {
  display: flex;
  align-items: center;
  gap: 1rem;
}

.boot-marks img {
  width: 88px;
  height: 88px;
  border-radius: 20px;
}

/* The MDV mark is square-ish artwork rather than an app icon, so no rounding
   and no cropping — whatever the SVG draws is what shows. */
.boot-mdv { border-radius: 0 !important; object-fit: contain; }

#boot .boot-name {
  font: 600 1.5rem/1 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: -0.02em;
  color: #fafafa;
}

#boot .boot-rule {
  width: 96px;
  height: 2px;
  border-radius: 2px;
  background: #a78bfa;   /* violet-400, the accent used everywhere else */
  transform-origin: center;
}

/*
   Half the width of the MDV mark plus the gap: the distance the Hub icon has
   to sit left of its final place in order to look centred on its own.
   88px + 16px gap = 104, halved = 52.
*/
#boot[data-show=""] .boot-hub  { animation: boot-hub 3000ms ease forwards; }
#boot[data-show=""] .boot-mdv  { animation: boot-mdv 3000ms ease forwards; }
#boot[data-show=""] .boot-name { animation: boot-rise 3000ms ease forwards; }
#boot[data-show=""] .boot-rule { animation: boot-draw 3000ms ease forwards; }

/* Only one mark to show: it stays centred and nothing slides. */
#boot[data-missing-mdv] .boot-mdv { display: none; }
#boot[data-missing-mdv][data-show=""] .boot-hub { animation: boot-mark 3000ms ease forwards; }

@keyframes boot-out {
  /* Everything is drawn by 76%; this holds to 80% so the finished composition
     sits still for a moment instead of fading while the rule is mid-stroke. */
  0%, 80%  { opacity: 1; }
  100%     { opacity: 0; visibility: hidden; }
}

/* Arrives centred, holds, then slides right into its place in the pair. */
@keyframes boot-hub {
  0%   { opacity: 0; transform: translateX(-52px) scale(0.86); }
  16%  { opacity: 1; transform: translateX(-52px) scale(1); }
  32%  { opacity: 1; transform: translateX(-52px) scale(1); }
  52%  { opacity: 1; transform: translateX(0) scale(1); }
  100% { opacity: 1; transform: translateX(0) scale(1); }
}

/* Fades into the space the Hub icon has just left. */
@keyframes boot-mdv {
  0%, 36% { opacity: 0; transform: translateX(-10px); }
  60%     { opacity: 1; transform: translateX(0); }
  100%    { opacity: 1; transform: translateX(0); }
}

/* The single-mark fallback, when there is no MDV logo to pair it with. */
@keyframes boot-mark {
  0%   { opacity: 0; transform: scale(0.86); }
  22%  { opacity: 1; transform: scale(1); }
  100% { opacity: 1; transform: scale(1); }
}

@keyframes boot-rise {
  0%, 54% { opacity: 0; transform: translateY(8px); }
  72%     { opacity: 1; transform: translateY(0); }
  100%    { opacity: 1; transform: translateY(0); }
}

@keyframes boot-draw {
  0%, 60% { transform: scaleX(0); opacity: 0; }
  64%     { transform: scaleX(0); opacity: 1; }
  76%     { transform: scaleX(1); opacity: 1; }
  100%    { transform: scaleX(1); opacity: 1; }
}

/* Asked for reduced motion: it appears and leaves, with nothing moving. */
@media (prefers-reduced-motion: reduce) {
  #boot[data-show] .boot-hub,
  #boot[data-show] .boot-mdv,
  #boot[data-show] .boot-name,
  #boot[data-show] .boot-rule { animation: none; }
}

/* ---------------------------------------------------------------------------
   The kiosk welcome.

   One bubble at the centre for the person who filled the form, the rest of the
   household and a few church icons orbiting it. Markup in
   templates/forms/public.php, positions in /assets/kiosk-welcome.js.

   Hand-written rather than utilities: this is keyframes and a glass treatment,
   neither of which a utility class expresses — and it survives a missed
   Tailwind rebuild, which this project has been caught by.

   Opt-in: invisible until the script sets data-show, so a scripting failure
   leaves the page underneath usable rather than covered.
   --------------------------------------------------------------------------- */

#welcome {
  position: fixed;
  inset: 0;
  z-index: 9998;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The ring centres in the space ABOVE the caption, not in the whole screen —
     otherwise the lowest bubble lands on the name on a short viewport. */
  padding-bottom: clamp(5rem, 16vh, 9rem);
  overflow: hidden;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  /* A token, not a literal: the orbs draw their separation gap in it. */
  --welcome-ground: #09090b;
  background: var(--welcome-ground);
}

html[data-form-theme="light"] #welcome { --welcome-ground: #fafafa; }
@media (prefers-color-scheme: light) {
  html[data-form-theme="system"] #welcome { --welcome-ground: #fafafa; }
}

#welcome[data-show] {
  visibility: visible;
  pointer-events: auto;
  animation: welcome-in 500ms ease forwards;
}

#welcome[data-done] {
  animation: welcome-out 500ms ease forwards;
  pointer-events: none;
}

/* The square the whole composition lives in, so the ring is never an ellipse. */
.welcome-system {
  position: relative;
  /* 78vh, not 92vh: #welcome reserves up to 16vh below for the caption, and a
     square sized to the WHOLE viewport does not fit in what is left. A 1024x768
     iPad — the actual welcome-desk screen — put the lowest bubble on the name. */
  width: min(92vw, 78vh, 660px);
  height: min(92vw, 78vh, 660px);
  display: flex;
  align-items: center;
  justify-content: center;
}

/*
   The ring. Its children are placed with rotate/translate from the centre, so
   turning this one element carries all of them round together.

   Slow on purpose: 90 seconds is movement you notice only if you look, which is
   what keeps it feeling calm rather than like a loading spinner.
*/
.welcome-orbit {
  position: absolute;
  inset: 0;
  animation: welcome-spin 90s linear infinite;
}

#welcome[data-show="still"] .welcome-orbit,
#welcome[data-done] .welcome-orbit { animation-play-state: paused; }

.welcome-orb {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -34px 0 0 -34px;   /* half the default size; overridden inline */
  border-radius: 9999px;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  animation: welcome-pop 800ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
  /*
     Bubbles overlap on purpose, and without this they merge into one shape. The
     ring is the screen's own colour, so against the background it is invisible
     and against the bubble behind it reads as a gap. On the parent rather than
     the inner, so the three .welcome-orb-inner variants keep their own shadows;
     the script sets the width from the bubble's size.
  */
  box-shadow: 0 0 0 var(--orb-ring, 3px) var(--welcome-ground);
}

/*
   The counter-spin. Same duration and easing as the ring, in reverse, so a
   bubble travels the circle without ever tipping — initials stay readable and
   the icons stay the right way up.
*/
/*
   The drift in.

   Its own element, between the orb and the inner. The orb's transform is what
   holds it on the ring and the inner's is the counter-spin — neither can also
   carry this, and an animation that touched either would throw every bubble at
   the middle of the screen.

   Overshoots slightly on the way in: the bubbles settle rather than stop dead.
*/
.welcome-orb-float {
  display: block;
  width: 100%;
  height: 100%;
  animation: welcome-drift 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes welcome-drift {
  from { transform: translate(var(--from-x, 0), var(--from-y, 0)) scale(0.4); opacity: 0; }
  60%  { opacity: 1; }
  to   { transform: translate(0, 0) scale(1); opacity: 1; }
}

.welcome-orb-inner {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 9999px;
  font-weight: 650;
  letter-spacing: -0.02em;
  animation: welcome-spin-back 90s linear infinite;
}

#welcome[data-show="still"] .welcome-orb-inner,
#welcome[data-done] .welcome-orb-inner { animation-play-state: paused; }

/* Adults: violet, lit from the top left so it reads as a sphere. */
.welcome-orb.is-adult .welcome-orb-inner {
  color: #09090b;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.5), rgba(255,255,255,0) 52%),
    linear-gradient(150deg, #c4b5fd 0%, #a78bfa 46%, #7c3aed 100%);
  box-shadow: 0 16px 34px -14px rgba(124, 58, 237, 0.6), inset 0 1px 0 rgba(255,255,255,0.4);
}

/* Children: the same family, one shade along. */
.welcome-orb.is-kid .welcome-orb-inner {
  color: #06202a;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.5), rgba(255,255,255,0) 52%),
    linear-gradient(150deg, #a5f3fc 0%, #67e8f9 46%, #0891b2 100%);
  box-shadow: 0 14px 28px -14px rgba(8, 145, 178, 0.6), inset 0 1px 0 rgba(255,255,255,0.4);
}

/*
   Icons, drawn the way the kiosk screensaver draws them: a coloured sphere with
   a white glyph, lit from the top left like the people are.

   They used to be flat dark tiles, which read as holes between the bubbles
   rather than as part of the same family of objects. --c is the icon's own
   colour, set inline by kiosk-welcome.js.
*/
.welcome-orb.is-icon .welcome-orb-inner {
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.55), rgba(255,255,255,0) 55%),
    linear-gradient(150deg, rgba(var(--c, 120,120,130), 0.95) 0%, rgba(var(--c, 120,120,130), 0.72) 100%);
  box-shadow: 0 14px 30px -14px rgba(var(--c, 120,120,130), 0.7), inset 0 1px 0 rgba(255,255,255,0.45);
}

/*
   The centre.

   Absolutely centred in the square rather than laid out in a column with the
   caption: a flex column centres the STACK, which put the core above the ring's
   centre and made the whole composition look misaligned.

   Outside .welcome-orbit, so it does not turn with the ring.
*/
.welcome-core {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.6);
  z-index: 2;
  /* A fallback only — the script sizes this from the ring's own box, so the
     core and the orbit stay in proportion at any viewport. */
  width: clamp(168px, 34vmin, 240px);
  height: clamp(168px, 34vmin, 240px);
  border-radius: 9999px;
  display: flex;
  align-items: center;
  justify-content: center;
  font: 650 clamp(2.5rem, 7vmin, 4rem)/1 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: -0.03em;
  color: #09090b;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.6), rgba(255,255,255,0) 54%),
    linear-gradient(150deg, #ddd6fe 0%, #a78bfa 44%, #6d28d9 100%);
  /* The ring of light Apple puts round the person at the centre. */
  box-shadow:
    0 0 0 10px rgba(167, 139, 250, 0.14),
    0 30px 60px -20px rgba(109, 40, 217, 0.7),
    inset 0 2px 0 rgba(255,255,255,0.5);
  opacity: 0;
  animation: welcome-core-pop 900ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

/* The caption sits under the whole system, not inside what centres it. */
.welcome-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: clamp(2rem, 8vh, 5rem);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  z-index: 3;
}

.welcome-core-name {
  font: 600 clamp(1.25rem, 3.4vmin, 1.75rem)/1.2 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: -0.02em;
  color: #fafafa;
  text-align: center;
  opacity: 0;
  animation: welcome-rise 700ms cubic-bezier(0.16, 1, 0.3, 1) 500ms forwards;
}

.welcome-title {
  font: 650 clamp(1.5rem, 4.4vmin, 2.5rem)/1.15 system-ui, -apple-system, "Segoe UI", sans-serif;
  letter-spacing: -0.02em;
  color: #fafafa;
  text-align: center;
  opacity: 0;
  animation: welcome-rise 700ms cubic-bezier(0.16, 1, 0.3, 1) 700ms forwards;
}

html[data-form-theme="light"] .welcome-core-name,
html[data-form-theme="light"] .welcome-title { color: #18181b; }
@media (prefers-color-scheme: light) {
  html[data-form-theme="system"] .welcome-core-name,
  html[data-form-theme="system"] .welcome-title { color: #18181b; }
}

@keyframes welcome-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes welcome-out { to { opacity: 0; visibility: hidden; } }
@keyframes welcome-spin      { to { transform: rotate(360deg); } }
@keyframes welcome-spin-back { to { transform: rotate(-360deg); } }

@keyframes welcome-rise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}

/*
   Scale only — no transform on .welcome-orb itself, because its inline
   transform is what holds it on the ring and an animation would overwrite it.
*/
@keyframes welcome-pop {
  0%   { opacity: 0; }
  60%  { opacity: 1; }
  100% { opacity: 1; }
}

/* Separate from welcome-pop because this one must preserve the centring
   translate — animating plain scale would drop it and the core would jump to
   the bottom-right of centre. */
@keyframes welcome-core-pop {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.6); }
  60%  { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
  100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

#welcome[data-show="still"] .welcome-orb,
#welcome[data-show="still"] .welcome-core-name,
#welcome[data-show="still"] .welcome-title {
  animation: none;
  opacity: 1;
}
#welcome[data-show="still"] .welcome-core {
  animation: none;
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .welcome-orbit, .welcome-orb-inner { animation: none; }
  /* Still arrives, just without travelling to do it. */
  .welcome-orb-float { animation: none; opacity: 1; transform: none; }
}


/* ---------------------------------------------------------------------------
   The loading orb.

   Ported by hand from the "orbs" shadcn registry (aicss.dev), variant M2 —
   "Expanding". That package ships React with CSS modules and Tailwind v4; this
   application is PHP with Tailwind v3 and a CSP that forbids inline script, so
   `npx shadcn add` was never going to run here. What is reproduced is the
   geometry and the timing, which is what the variant actually is:

     eight dots, authored on a 28px stage at radius 7, collapsing to radius 1.5
     and blooming back out twice per 4.8s, while the whole figure turns once
     every 9.6s.

   M2's positions are fixed, so they are written out below rather than computed
   at runtime — no script decides how this looks, which means it cannot be
   half-drawn if one fails to load.

   Hand-written rather than utilities, like the kiosk welcome: keyframes are not
   something a utility class expresses, and it survives a missed Tailwind build.
   --------------------------------------------------------------------------- */

/*
   A <dialog>, opened with showModal(), so the browser puts it in the TOP LAYER.

   That is the whole reason for the element choice. Hub's own dialogs — Mark
   inactive, Delete this person — are native modals, and the top layer sits
   above every z-index there is; a plain div at z-index 9997 appeared BEHIND
   them when a form inside one was submitted. Nothing stacks above the top
   layer, so this is now correct in every flow.

   The dialog paints its own dim rather than leaning on ::backdrop, so the
   fallback path for a browser too old for showModal() looks identical.
*/
dialog#loading {
  /* Closed is the UA default, and author styles beat UA styles — so the flex
     has to live on [open] or this would be on screen permanently. */
  display: none;
  position: fixed;
  inset: 0;
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  align-items: center;
  justify-content: center;
  background: rgba(9, 9, 11, 0.66);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  color: #fafafa;
}
/*
   The fade is an ANIMATION, not a transition.

   A transition from display:none needs the browser to render one frame at the
   old value and another at the new one, which meant driving it from
   requestAnimationFrame — and rAF does not fire in a background tab. The dim
   would then sit at opacity 0 while still swallowing every tap: invisible and
   blocking, which is worse than not having it. An animation starts by itself
   the moment the element is displayed, with no frame to wait for.
*/
dialog#loading[open],
dialog#loading[data-fallback] {
  display: flex;
  /* No fill-mode, and the keyframes name only the START. If the animation never
     runs at all — a paused document, an old engine — the element falls back to
     its own opacity of 1 and simply appears. With `both` it would have held the
     0% frame instead: invisible while still swallowing taps, the one failure
     this must not have. */
  animation: loading-in .18s ease;
}
@keyframes loading-in { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  dialog#loading[open],
  dialog#loading[data-fallback] { animation: none; opacity: 1; }
}

/* The dim is the dialog's own background; the browser's backdrop would darken
   it a second time. */
dialog#loading::backdrop { background: transparent; }

/* Alone on the screen now, with no panel behind it, so it carries full size. */
dialog#loading .loader { --size: 1; }

/* The eight-dot orb, no longer drawn anywhere — kept so reverting is a markup
   swap. Delete this and the `.orb*` rules together if it is ever settled. */
dialog#loading .orb {
  width: 48px;
  height: 48px;
  --orb-k: 1.7143;
}


/* ---------------------------------------------------------------------------
   The loading orb, second edition.

   From Uiverse.io by andrew-manzyk, rethemed to Hub's emerald. Ian chose it on
   2026-09-21 to replace the eight-dot orb, in BOTH the places Hub spun one: the
   page loader and the signage upload panel, whose own comment said it used
   "the same orb the rest of Hub uses" and would otherwise have stopped being
   true.

   The eight-dot rules are left in place further down, unused. This one costs
   more to draw (see below) and was taken on to be watched; leaving the old
   figure there means going back is a markup swap rather than a rewrite.

   How it works, because nothing about it is obvious: seven polygons live in an
   SVG mask, each rotating about a different origin. Every polygon is blurred by
   7px, and the mask as a whole is pushed through `contrast(15)`, which drags
   those soft edges back to hard ones — so where two blurred shapes overlap they
   FUSE, and the figure reads as one liquid body rather than seven triangles.
   `roundness` rides the contrast up and down, which is what makes it breathe.
   The first polygon is a black square: the mask starts hidden and the white
   triangles cut into it.

   ONE SET OF DEFS FOR THE PAGE. `#clipping` is an id, and two of them on one
   document is invalid — the signage panel renders a `.loader` with no `<svg>`
   of its own and points at the one in the footer, which is on every page.

   Costs more than the dots did: those animated `transform` only, which the
   compositor does for free, while this repaints a filtered layer every frame.
   Watched deliberately rather than assumed harmless — if a slow machine feels
   worse for it, the whole thing is these few rules plus six lines of markup.
   --------------------------------------------------------------------------- */
.loader {
  /* Hub's violet, the same family as the welcome orb's
     #ddd6fe -> #a78bfa -> #7c3aed. NOT emerald: that is the confirmation
     colour in this app, not the brand. */
  --color-one: #ddd6fe;      /* violet-200 — the lit top edge */
  --color-two: #8b5cf6;      /* violet-500 — the deep underside */
  --color-three: #a78bfa80;
  --color-four: #8b5cf680;
  --color-five: #a78bfa40;
  --time-animation: 2s;
  --size: 1;
  position: relative;
  /* The original sizes only ::before and .box, so the element stretched to its
     container and the glow came out a different size from the orb inside it. */
  width: 100px;
  height: 100px;
  border-radius: 50%;
  transform: scale(var(--size));
  box-shadow: 0 0 25px 0 var(--color-three), 0 20px 50px 0 var(--color-four);
  animation: colorize calc(var(--time-animation) * 3) ease-in-out infinite;
}
.loader::before {
  content: "";
  position: absolute; top: 0; left: 0;
  width: 100px; height: 100px;
  border-radius: 50%;
  border-top: solid 1px var(--color-one);
  border-bottom: solid 1px var(--color-two);
  background: linear-gradient(180deg, var(--color-five), var(--color-four));
  box-shadow: inset 0 10px 10px 0 var(--color-three), inset 0 -10px 10px 0 var(--color-four);
}
.loader .box {
  width: 100px; height: 100px;
  background: linear-gradient(180deg, var(--color-one) 30%, var(--color-two) 70%);
  mask: url(#clipping);
  -webkit-mask: url(#clipping);
}
.loader svg { position: absolute; }
/* contrast 8 and blur 12, not the 15/7 as published: at the original values
   the two large triangles stay hard-edged through the high-contrast phase and
   the figure reads as an HOURGLASS. Softer blur with a lower ceiling keeps the
   shapes fused into blobs the whole way round, which is the point of it. */
.loader svg #clipping { filter: contrast(8); animation: roundness calc(var(--time-animation) / 2) linear infinite; }
.loader svg #clipping polygon { filter: blur(12px); }
.loader svg #clipping polygon:nth-child(1) { transform-origin: 75% 25%; transform: rotate(90deg); }
.loader svg #clipping polygon:nth-child(2) { transform-origin: 50% 50%; animation: rotation var(--time-animation) linear infinite reverse; }
.loader svg #clipping polygon:nth-child(3) { transform-origin: 50% 60%; animation: rotation var(--time-animation) linear infinite; animation-delay: calc(var(--time-animation) / -3); }
.loader svg #clipping polygon:nth-child(4) { transform-origin: 40% 40%; animation: rotation var(--time-animation) linear infinite reverse; }
.loader svg #clipping polygon:nth-child(5) { transform-origin: 40% 40%; animation: rotation var(--time-animation) linear infinite reverse; animation-delay: calc(var(--time-animation) / -2); }
.loader svg #clipping polygon:nth-child(6) { transform-origin: 60% 40%; animation: rotation var(--time-animation) linear infinite; }
.loader svg #clipping polygon:nth-child(7) { transform-origin: 60% 40%; animation: rotation var(--time-animation) linear infinite; animation-delay: calc(var(--time-animation) / -1.5); }

@keyframes rotation { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes roundness {
  0%   { filter: contrast(8); }
  20%  { filter: contrast(3); }
  40%  { filter: contrast(3); }
  60%  { filter: contrast(8); }
  100% { filter: contrast(8); }
}
/*
   POSITIVE rotation, and only 16deg of it.

   Violet sits near 262deg, and the published animation rotates NEGATIVE — from
   orange that is a warm slide, but from violet it walks straight into blue, and
   the orb stopped looking like Hub at all. Going the other way moves toward
   magenta, which stays inside the brand. Sixteen degrees is a shimmer; more
   than about twenty-five and it reads as a colour change.
*/
@keyframes colorize {
  0%   { filter: hue-rotate(0deg); }
  50%  { filter: hue-rotate(16deg); }
  100% { filter: hue-rotate(0deg); }
}

/*
   Still an orb standing still.

   Nothing animates, and the mask is left at full contrast so the figure keeps
   its hard edge rather than dissolving into the blur. Somebody who has asked
   for less motion still needs to see that the page is working.
*/
@media (prefers-reduced-motion: reduce) {
  .loader,
  .loader svg #clipping,
  .loader svg #clipping polygon { animation: none; }
  .loader svg #clipping { filter: contrast(8); }
}

/* The 28px stage, scaled to the rendered size by --orb-k. */
.orb {
  position: relative;
  width: 20px;
  height: 20px;
  --orb-k: 0.7143;
}
/* The figure turns; the dots inside it expand and contract. Two animations on
   two elements, because one element cannot hold both transforms. */
.orb-spin {
  position: absolute;
  inset: 0;
  animation: orb-twist 9.6s linear infinite;
}
.orb-dot {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 3px;
  height: 3px;
  margin: -1.5px 0 0 -1.5px;
  border-radius: 50%;
  background: currentColor;
  animation: orb-morph 4.8s cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}

.orb-dot:nth-child(1){--m-1:0.0px,-1.5px;--m-2:0.0px,-7.0px}
.orb-dot:nth-child(2){--m-1:1.1px,-1.1px;--m-2:4.9px,-4.9px}
.orb-dot:nth-child(3){--m-1:1.5px,0.0px;--m-2:7.0px,0.0px}
.orb-dot:nth-child(4){--m-1:1.1px,1.1px;--m-2:4.9px,4.9px}
.orb-dot:nth-child(5){--m-1:0.0px,1.5px;--m-2:0.0px,7.0px}
.orb-dot:nth-child(6){--m-1:-1.1px,1.1px;--m-2:-4.9px,4.9px}
.orb-dot:nth-child(7){--m-1:-1.5px,0.0px;--m-2:-7.0px,0.0px}
.orb-dot:nth-child(8){--m-1:-1.1px,-1.1px;--m-2:-4.9px,-4.9px}

/* The four stops of M2 are centre, ring, centre, ring — so --m-1 and --m-3 are
   the same value, as are --m-2 and --m-4. Only two are stored. */
@keyframes orb-morph {
  0%, 5%   { transform: translate(var(--m-1)); }
  25%, 30% { transform: translate(var(--m-2)); }
  50%, 55% { transform: translate(var(--m-1)); }
  75%, 80% { transform: translate(var(--m-2)); }
  100%     { transform: translate(var(--m-1)); }
}
@keyframes orb-twist {
  from { transform: scale(var(--orb-k, 1)) rotate(0deg); }
  to   { transform: scale(var(--orb-k, 1)) rotate(360deg); }
}

@media (prefers-reduced-motion: reduce) {
  .orb-spin, .orb-dot { animation: none; }
  /* Still legible standing still: the dots rest on the ring. */
  .orb-dot { transform: translate(var(--m-2)); }
}

/* ---------------------------------------------------------------------------
   The collapsing sidebar.

   Ported from a React reference the user supplied. Its own dependencies — a
   private icon package, a GlideMenu primitive and a token set (bg-hover-2,
   text-ink, shadow-overlay) — exist in neither of these apps, so what is
   reproduced is the behaviour: a rail that collapses without the icons moving,
   copy that fades rather than squashing, and a highlight that glides between
   rows instead of blinking on and off.

   Hand-written rather than utilities: none of this is expressible as a class,
   and it survives a missed Tailwind build.
   --------------------------------------------------------------------------- */

[data-sidebar] {
  --sidebar-open: 14rem;    /* 224px */
  --sidebar-rail: 3.25rem;  /* 52px  */
  width: var(--sidebar-open);
  transition: width 280ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-sidebar][data-collapsed] { width: var(--sidebar-rail); }

/*
   Copy fades and slides a little; it is never re-wrapped. The inner column
   keeps its full width the whole time, so the labels are simply clipped by the
   narrowing aside rather than being re-laid out on every frame.
*/
.sidebar-copy {
  transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1),
              transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-collapsed] .sidebar-copy {
  opacity: 0;
  transform: translateX(-8px);
  pointer-events: none;
}

/* Only one of the two chevrons is ever reachable. */
.sidebar-expand { opacity: 0; pointer-events: none; }
[data-collapsed] .sidebar-expand { opacity: 1; pointer-events: auto; }
[data-collapsed] .sidebar-collapse { opacity: 0; pointer-events: none; }
[data-collapsed] .sidebar-brand { opacity: 0; pointer-events: none; }
.sidebar-expand svg { transform: scaleX(-1); }

/*
   The gliding highlight.

   One absolutely-positioned block per group that moves to whichever row the
   pointer is over, rather than each row painting its own hover. Transform and
   size only — both compositor properties — so it slides at any refresh rate.
*/
[data-glide] { position: relative; }
.sidebar-glide {
  position: absolute;
  left: 0.5rem;
  right: 0.5rem;
  top: 0;
  height: 0;
  border-radius: 0.5rem;
  background: rgb(39 39 42);          /* zinc-800 */
  opacity: 0;
  pointer-events: none;
  transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1),
              height 180ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 140ms ease;
}
[data-glide][data-hovering] .sidebar-glide { opacity: 1; }

/* The active row keeps its tint, but yields the hover to the glide so the two
   never stack into a brighter block. */
[data-glide][data-hovering] .sidebar-row { background-color: transparent; }

@media (prefers-reduced-motion: reduce) {
  [data-sidebar], .sidebar-copy, .sidebar-glide { transition: none; }
}

/* ---------------------------------------------------------------------------
   Figures that are nobody else's business.

   Ian, 2026-09-22: the giving total on the dashboard should be blurred until a
   button is held. The dashboard is opened in the lobby, on a phone in a pew,
   and on the office screen with people walking behind it, and the all-time
   total is the one number on it that nobody standing nearby should read by
   accident.

   THIS IS A SHOULDER, NOT A LOCK. The figure is in the page like any other
   text: anybody who can open the dashboard can read it from the page source,
   and it is still spoken by a screen reader, which is correct — the person
   using one is the person holding the button. Access to giving is decided by
   the `giving.view` permission, which is what actually keeps this card off
   somebody's screen.

   Blur in `em`, so it tracks the font size and one rule covers the 3xl figure
   on the dashboard and anything smaller it is later used on.
   --------------------------------------------------------------------------- */

.money-private {
  filter: blur(0.3em);
  /* Selecting blurred text and reading it out of the selection would walk
     straight around the blur. */
  user-select: none;
  -webkit-user-select: none;
  transition: filter 120ms ease;
}

.money-private.is-shown {
  filter: none;
  user-select: auto;
  -webkit-user-select: auto;
}

/* The button is held, not tapped, so it must not take a text selection, a
   long-press callout or a context menu with it. */
.hold-eye {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  touch-action: manipulation;
}

@media (prefers-reduced-motion: reduce) {
  .money-private { transition: none; }
}

/* ---------------------------------------------------------------------------
   The person profile banner.

   Photo, name and contact details in one row; the status pill and Edit profile
   in another below it on a phone, and off to the right from sm up.

   WHAT WENT WRONG FLAT. All three sat in one `flex flex-wrap` row, and the
   name column carried `flex-1 min-w-0` so it was free to shrink to nothing.
   An email address is one unbreakable token, so it did not wrap — it
   overflowed its own box and ran along UNDER the status pill beside it. Ian
   sent a screenshot on 2026-09-22 with "Church Team" sitting on top of
   "alexvanini@gmail.com".

   Two separate faults, and both need fixing: the row had to stop competing
   for width, AND the address had to be allowed to break. Stacking alone would
   still overflow on a narrow enough screen, and `overflow-wrap` alone would
   still put a two-line address beside a button.

   Hand-written because `sm:items-center`, `sm:flex-wrap`, `sm:gap-5`,
   `sm:ml-auto`, `sm:h-24` and `sm:w-24` are none of them in the shipped
   tailwind.css, and there is no build step on this machine.
   --------------------------------------------------------------------------- */

.profile-banner {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.profile-banner-main {
  display: flex;
  align-items: center;
  gap: 1rem;
  min-width: 0;
}

.profile-banner-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

/* 80px on a phone, where it is sharing the row with a name; the 96px it always
   was from sm up. More specific than `.h-24`/`.w-24`, which stay on the
   element so the layout still reads correctly in the markup. */
.profile-photo > img,
.profile-photo > span {
  height: 5rem;
  width: 5rem;
}

/* The fix for the overlap itself. `anywhere` rather than `break-word`: an
   address with no spaces in it gives `break-word` nothing to work with.

   The NAME needs it too, and that is the case Ian hit second: "Alexandra
   Machado" wrapped to two lines and the MDV Kids pill landed on top of the
   second one. A long single name — no spaces at all — would otherwise run
   straight out of the column the same way the address did. */
.profile-contact { min-width: 0; }
.profile-contact > span,
.profile-banner-main h1 { overflow-wrap: anywhere; }

@media (min-width: 640px) {
  .profile-banner {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: 1.25rem;
  }
  .profile-banner-main { flex: 1 1 auto; }
  .profile-banner-actions { margin-left: auto; }

  .profile-photo > img,
  .profile-photo > span {
    height: 6rem;
    width: 6rem;
  }
}

/* ---------------------------------------------------------------------------
   The workflow step list: one list, two shapes.

   On a phone the steps scroll SIDEWAYS as a row of chips, directly under the
   title. Stacked as a column — which is what a `lg:` grid does below its
   breakpoint — the step list plus the two link groups under it pushed the
   first card most of a screen down. Getting to the work meant scrolling past
   the whole navigation, every time, and that was the single biggest reason
   this page was unusable on a phone. Ian, 2026-09-22.

   `.rail` / `.rail-item` are the same thing under a neutral name, used by the
   person profile's tab rail. ONE rule set with two selectors rather than a
   second copy: the workflow markup is not touched, and the two cannot drift.

   Hand-written rather than utilities, and not by preference: `lg:block`,
   `lg:space-y-1`, `lg:whitespace-normal`, `lg:overflow-x-visible` and
   `sm:mx-0` are NONE of them in the shipped tailwind.css, there is no build
   step on this machine, and a class that was never generated does nothing at
   all without saying so.
   --------------------------------------------------------------------------- */

.wf-steps-title { display: none; }

.wf-steps,
.rail {
  display: flex;
  gap: 0.5rem;
  overflow-x: auto;
  /* Bleeds to the screen edge so the row reads as scrollable rather than as a
     list that happens to be cut off. */
  margin: 0 -1rem;
  padding: 0 1rem 0.5rem;
  scrollbar-width: none;
}
.wf-steps::-webkit-scrollbar,
.rail::-webkit-scrollbar { display: none; }
.wf-steps > li,
.rail > li { flex: none; }

.wf-step,
.rail-item {
  white-space: nowrap;
  border: 1px solid rgb(39 39 42);       /* zinc-800 */
}

/* Completed, and My/All ready cards. On a phone "Mine" is already a chip at
   the top of the page and the rest are in the More menu, so these would be a
   third copy of the same controls between the reader and the cards. */
.wf-steps-extra { display: none; }

/* A group heading inside a rail: it reads as a heading down a column and as
   a stray word in a sideways row, so the row simply does without it. */
.rail-label { display: none; }

@media (min-width: 1024px) {
  .wf-steps-title { display: block; }

  .wf-steps,
  .rail {
    display: block;
    overflow-x: visible;
    margin: 0;
    padding: 0;
  }
  .wf-steps > li + li,
  .rail > li + li { margin-top: 0.25rem; }

  .wf-step,
  .rail-item {
    white-space: normal;
    border-color: transparent;
  }

  .wf-steps-extra { display: block; }
  .rail-label { display: block; }
}

/* ---------------------------------------------------------------------------
   Sidebar groups.

   A <details>, not a button and a panel. It opens and closes with no
   JavaScript at all, it is already a disclosure to a screen reader without a
   single aria- attribute, and Enter/Space work because the browser wrote that
   part. sidebar.js only remembers the state across page loads — Hub is
   server-rendered, so every click is a fresh document and anything not written
   down is forgotten.

   Hand-written rather than utilities, like the rest of this block: a class
   missing from the shipped tailwind.css fails silently, and the sidebar is not
   a place to find that out.
   --------------------------------------------------------------------------- */

.nav-group + .nav-group,
.nav-group:first-of-type { margin-top: 0.375rem; }

.nav-group-head {
  display: flex;
  align-items: center;
  gap: 0.375rem;
  height: 1.75rem;
  padding: 0 0.5rem 0 0.625rem;
  border-radius: 0.5rem;
  cursor: pointer;
  user-select: none;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgb(141 141 151);               /* zinc-500 */
  transition: color 140ms ease;

  /* The disclosure triangle, in both dialects. Firefox and Chrome take
     list-style; Safari needs its pseudo-element turned off by name. */
  list-style: none;
}
.nav-group-head::-webkit-details-marker { display: none; }

.nav-group-head:hover { color: rgb(212 212 216); }   /* zinc-300 */
.nav-group-head:focus-visible {
  outline: 2px solid rgb(139 92 246);                /* violet-500 */
  outline-offset: -2px;
  color: rgb(212 212 216);
}

.nav-group-label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.nav-group-chevron {
  width: 13px;
  height: 13px;
  flex: none;
  opacity: 0.7;
  transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.nav-group[open] > .nav-group-head .nav-group-chevron { transform: rotate(180deg); }

/* The nav sets space-y-px on its own children; rows inside a group are a level
   down and would otherwise sit flush against each other and the summary. */
.nav-group > .sidebar-row { margin-top: 1px; }

/*
   The rail.

   At 52px there is no room for a heading and nothing to read if there were, so
   the headers go and the groups stand open — every icon stays one click away,
   which is the whole point of the rail. sidebar.js sets `open` on the way in
   and puts each group back the way it was on the way out; forcing it here with
   display would fight the element's own state and leave it wrong when the
   sidebar expands again.
*/
[data-collapsed] .nav-group-head { display: none; }
[data-collapsed] .nav-group + .nav-group,
[data-collapsed] .nav-group:first-of-type { margin-top: 1px; }

@media (prefers-reduced-motion: reduce) {
  .nav-group-chevron { transition: none; }
}

/* ---------------------------------------------------------------------------
   The bottom bar.

   A floating glass capsule, from the pattern the user supplied: the bar lifts
   off the bottom edge, tints the page behind it with the app's own accent, and
   carries two inset highlight rings so it reads as a piece of glass rather than
   a flat panel.

   The sliding pill from the transitions.dev recipe is kept and restyled — the
   original marks its active item with a static background, but a pill that
   travels between them says which one you came from as well as which one you
   are on. Its width and transform are still written by assets/tabbar.js from
   the selected cell's own measurements.

   --tab-accent is the one place the colour is stated. Hub is violet; Services
   uses its own green in the equivalent file.
   --------------------------------------------------------------------------- */

.tabbar {
  --tab-accent: 167, 139, 250;         /* violet-400, and only ever the TEXT */
  --tab-ease: cubic-bezier(0.22, 1, 0.36, 1);

  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  /* Lifted off the edge, and further on a phone with a home indicator. */
  bottom: calc(0.75rem + env(safe-area-inset-bottom));
  z-index: 30;

  width: calc(100% - 1.25rem);
  max-width: 32.5rem;
  padding: 0.5rem;
  border-radius: 99rem;

  /*
     Frosted, not tinted. The bar takes its colour from whatever is behind it
     rather than painting the accent across the whole capsule — the accent is
     for the one item you are on, and a bar that is already violet leaves it
     nowhere to show. Hub is always dark, so this is the dark frosting; Services
     does the same through its theme tokens and flips with the device.
  */
  background: rgba(24, 24, 27, 0.66);
  border: 1px solid rgba(255, 255, 255, 0.10);
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5);

  /* Blur hard, and barely touch saturation. A saturation boost is what makes
     frosted glass look expensive, but it also drags whatever colour is passing
     underneath — a violet button, a green badge — up into the glass and smears
     it across the bar. Neutral was the ask, so the blur does the work alone. */
  backdrop-filter: blur(24px) saturate(100%);
  -webkit-backdrop-filter: blur(24px) saturate(100%);
}

/*
   The glass edge. Two inset rings — light from above, light from below — which
   is what separates a pane of glass from a coloured rectangle.
*/
.tabbar::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    inset 2px 2px 5px -2px rgba(255, 255, 255, 0.28),
    inset -2px -2px 5px 2px rgba(255, 255, 255, 0.18),
    inset 0 -2px 0 rgba(255, 255, 255, 0.12);
  pointer-events: none;
}

.tabbar-row {
  position: relative;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
}

.tabbar-item {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.125rem;
  min-width: 0;
  padding: 0.5rem 0.25rem;
  border-radius: 99rem;
  font-size: 0.6875rem;
  font-weight: 600;
  line-height: 1;
  color: rgb(161 161 170);             /* zinc-400 */
  list-style: none;
  cursor: pointer;
  transition: color 0.18s var(--tab-ease), transform 0.18s var(--tab-ease);
}
.tabbar-item::-webkit-details-marker { display: none; }

/* The selected one, and the More sheet while it is open. */
/* The selected one, and the More sheet while it is open. The accent lives here
   and nowhere else on the bar. */
.tabbar-item.is-on,
details[data-menu][open] > .tabbar-item {
  color: rgba(var(--tab-accent), 1);
}

.tabbar-item:active { transform: scale(0.97); }

/*
   The pill the selection sits in. A lift in the glass rather than a colour —
   the accent is doing its work on the label, and a coloured pill underneath
   coloured text would only fight it.
*/
.tabbar-pill {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 0;
  border-radius: 99rem;
  background: rgba(255, 255, 255, 0.13);
  box-shadow:
    inset 1px 1px 3px -1px rgba(255, 255, 255, 0.28),
    inset -1px -1px 3px 0 rgba(255, 255, 255, 0.18);
  transform: translateX(0);
  transition:
    transform 250ms var(--tab-ease),
    width 250ms var(--tab-ease);
  will-change: transform, width;
  z-index: 0;
  pointer-events: none;
}

/*
   Set while the pill is being PLACED rather than moved — on first paint, and on
   resize. Without it the pill sweeps in from zero width on every page load,
   which on a server-rendered app is every tap.
*/
.tabbar-pill[data-instant] { transition: none; }

@media (prefers-reduced-motion: reduce) {
  .tabbar-pill, .tabbar-item { transition: none; }
}

/* ---------------------------------------------------------------------------
   Task rows.

   The grammar from the reference component: a status badge, the title, a state
   pill, a chevron, and a panel that opens by growing a grid track from 0fr to
   1fr. That track trick is the whole reason this needs no measured heights and
   no JavaScript beyond a class toggle — the row animates to its own content.

   Not carried over: the reference's running/failed/retry states. Those describe
   an agent working; a task handed to a person is open, done, or late.
   --------------------------------------------------------------------------- */

.task-row {
  border-radius: 14px;
  background: rgb(24 24 27);
  box-shadow: inset 0 0 0 1px rgb(39 39 42);
  overflow: hidden;
  transition: background-color .2s ease;
  /* Each row a beat after the one above, so the list assembles. */
  animation: task-enter 450ms cubic-bezier(0.23, 1, 0.32, 1) var(--enter, 0ms) both;
}
.task-row:hover { background: rgb(30 30 34); }

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

.task-head {
  display: flex;
  align-items: center;
  gap: 0.625rem;
  width: 100%;
  padding: 0.75rem 0.75rem;
  text-align: left;
}

/* Empty when the task is open: a ring is a thing waiting to be filled. */
.task-badge {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.375rem;
  height: 1.375rem;
  flex: 0 0 auto;
  border-radius: 9999px;
  box-shadow: inset 0 0 0 2px rgb(63 63 70);
  color: transparent;
}
.task-badge.is-done { background: rgb(34 197 94); box-shadow: none; color: #0b0b0e; }
.task-badge.is-late { background: rgb(239 68 68); box-shadow: none; color: #fff; }

.task-title {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 0.875rem;
  font-weight: 500;
  color: rgb(244 244 245);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.task-pill {
  flex: 0 0 auto;
  border-radius: 9999px;
  padding: 0.125rem 0.5rem;
  font-size: 0.6875rem;
  font-weight: 500;
  color: rgb(161 161 170);
  background: rgb(39 39 42);
}
.task-pill.is-soon { color: rgb(253 224 71); background: rgba(253, 224, 71, 0.12); }
.task-pill.is-late { color: rgb(252 165 165); background: rgba(239, 68, 68, 0.15); }
.task-pill.is-done { color: rgb(134 239 172); background: rgba(34, 197, 94, 0.12); }

.task-chevron {
  flex: 0 0 auto;
  display: flex;
  color: rgb(141 141 151);
  transition: transform .3s cubic-bezier(0.23, 1, 0.32, 1);
}
.task-row[data-open] .task-chevron { transform: rotate(180deg); }

/*
   0fr to 1fr. The row grows to whatever the detail turns out to be, with no
   height measured anywhere — which is what keeps this to a class toggle.
*/
.task-detail {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows .3s cubic-bezier(0.23, 1, 0.32, 1), opacity .3s ease;
}
.task-row[data-open] .task-detail { grid-template-rows: 1fr; opacity: 1; }
.task-detail-inner {
  overflow: hidden;
  display: grid;
  grid-template-columns: 1.375rem 1fr;
  gap: 0.625rem;
  padding: 0 0.75rem 0.75rem;
}
/* The line under the badge, tying the detail to the row it belongs to. */
.task-rule { justify-self: center; width: 1px; height: 100%; background: rgb(39 39 42); }

.task-facts { margin-top: .5rem; display: flex; flex-wrap: wrap; gap: .25rem 1.5rem; }
.task-facts > div { display: flex; gap: .4rem; align-items: baseline; }
.task-facts dt { font-size: .6875rem; text-transform: uppercase; letter-spacing: .06em; color: rgb(141 141 151); }
.task-facts dd { margin: 0; font-size: .8125rem; color: rgb(212 212 216); }

@media (prefers-reduced-motion: reduce) {
  .task-row, .task-chevron, .task-detail { animation: none; transition: none; }
}

/* The person picker on the tasks screen, and files on a task row. */
.person-picker { border-radius: .375rem; }
.person-list {
  max-height: 11rem;
  overflow-y: auto;
  border: 1px solid rgb(63 63 70);
  border-top: 0;
  border-radius: 0 0 .375rem .375rem;
  background: rgb(9 9 11);
}
.person-option {
  display: flex;
  align-items: center;
  padding: .4rem .75rem;
  font-size: .8125rem;
  color: rgb(212 212 216);
  cursor: pointer;
}
.person-option:hover { background: rgb(39 39 42); }
/* The radio is visually hidden, so the checked state has to be drawn here. */
.person-option:has(input:checked) { background: rgba(139, 92, 246, 0.16); color: rgb(221 214 254); }
.person-option:has(input:focus-visible) { outline: 2px solid rgb(167 139 250); outline-offset: -2px; }

.task-file {
  display: inline-flex;
  align-items: center;
  gap: .25rem;
  max-width: 100%;
  border-radius: 9999px;
  background: rgb(39 39 42);
  padding: .125rem .25rem .125rem .5rem;
}
.task-file > a {
  display: inline-flex;
  align-items: center;
  gap: .375rem;
  min-width: 0;
  font-size: .6875rem;
  color: rgb(212 212 216);
}
.task-file > a:hover { color: rgb(244 244 245); }
.task-file-size { flex: 0 0 auto; color: rgb(141 141 151); }
.task-file button { padding: 0 .3rem; color: rgb(141 141 151); }
.task-file button:hover { color: rgb(244 244 245); }

.task-attach {
  display: inline-flex;
  align-items: center;
  gap: .375rem;
  border-radius: .375rem;
  border: 1px dashed rgb(63 63 70);
  padding: .3rem .6rem;
  font-size: .6875rem;
  color: rgb(141 141 151);
  cursor: pointer;
}
.task-attach:hover { border-color: rgb(82 82 91); color: rgb(212 212 216); }

/* "This has something attached", on a row that has not been opened. */
.task-clip {
  display: inline-flex;
  align-items: center;
  gap: .15rem;
  flex: 0 0 auto;
  font-size: .6875rem;
  color: rgb(141 141 151);
}

/* ---------------------------------------------------------------------------
 * Public forms, one question at a time
 *
 * Applied only once form-steps.js has added .fsteps — so a form with no script
 * keeps the card, the spacing and the scroll it has always had. Everything
 * here is plain CSS rather than utilities because it is one component with a
 * shape of its own, and because a utility set this specific would need a
 * Tailwind rebuild every time a value moved.
 * ------------------------------------------------------------------------ */
.fsteps {
  border: 0;
  background: none;
  box-shadow: none;
  padding: 0;
  /* The stage is tall enough that a question sits near eye level rather than
     pinned to the top of the viewport, which is what makes it feel like one
     screen per question rather than a very short form. */
  min-height: 58vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* space-y-* on the form would still be putting margins between steps that are
   no longer stacked. */
.fsteps > * + * { margin-top: 0; }

.fsteps [data-step] {
  /* The question, not the page, carries the reading width. */
  max-width: 44rem;
}

/*
   Hiding the steps that are not current, and the reason this needs saying at
   all: Tailwind's preflight ships

     [hidden]:where(:not([hidden=until-found])) { display: none }

   and :where() contributes ZERO specificity, so that rule scores the same as
   one class — which `class="block"` on every field label then beats on source
   order. Setting el.hidden looks completely correct in the inspector and does
   nothing on screen. Two attribute selectors plus a class puts this at 0-3-0,
   comfortably above .block, with no !important needed.
*/
.fsteps [data-step][hidden] { display: none; }

/* The entrance. Direction-aware, so going back feels like going back. */
.fsteps [data-step].from-next { animation: fstep-in-next 0.34s cubic-bezier(0.22, 1, 0.36, 1) both; }
.fsteps [data-step].from-back { animation: fstep-in-back 0.34s cubic-bezier(0.22, 1, 0.36, 1) both; }

@keyframes fstep-in-next {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}
@keyframes fstep-in-back {
  from { opacity: 0; transform: translateY(-14px); }
  to   { opacity: 1; transform: none; }
}

/*
   The question itself. The template writes text-lg/sm:text-xl on this span for
   the stacked form; two attribute selectors plus an element out-specify a
   single utility class, so this wins wherever app.css sits in the order.
*/
.fsteps [data-step] > span:first-child,
.fsteps [data-step] > legend {
  font-size: clamp(1.45rem, 4.6vw, 2.1rem);
  font-weight: 600;
  line-height: 1.22;
  letter-spacing: -0.02em;
  margin-bottom: 0.85rem;
  text-wrap: balance;
}

.fsteps [data-step] input:not([type=radio]):not([type=checkbox]),
.fsteps [data-step] textarea,
.fsteps [data-step] select {
  font-size: clamp(1.05rem, 3.2vw, 1.35rem);
  padding: 0.85rem 1rem;
}

/* --- the chrome under the stage ----------------------------------------- */
.fsteps-chrome { margin-top: 1.75rem; }

/*
   The same zero-specificity trap as the steps themselves, one layer up: the
   Back and OK buttons are display:inline-flex, which out-ranks preflight's
   [hidden] rule, so hiding Back on the first question did nothing at all.
   Scoped to the chrome so it cannot reach into the form's own fields.
*/
.fsteps-chrome [hidden] { display: none; }

.fsteps-bar {
  height: 3px;
  border-radius: 99rem;
  background: rgba(161, 161, 170, 0.22);
  overflow: hidden;
  margin-bottom: 1rem;
}
.fsteps-bar i {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: rgb(167 139 250);              /* violet-400, Hub's accent */
  transition: width 0.34s cubic-bezier(0.22, 1, 0.36, 1);
}

.fsteps-foot {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.fsteps-back,
.fsteps-next {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  border-radius: 0.6rem;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.14s cubic-bezier(0.22, 1, 0.36, 1), background 0.18s;
}
.fsteps-back:active,
.fsteps-next:active { transform: scale(0.97); }

.fsteps-back {
  padding: 0.55rem 0.7rem;
  border: 1px solid rgba(161, 161, 170, 0.35);
  background: none;
  color: inherit;
}

.fsteps-next {
  padding: 0.7rem 1.15rem;
  border: 0;
  font-size: 1.05rem;
  background: rgb(167 139 250);
  color: rgb(9 9 11);
}
.fsteps-next:hover { background: rgb(196 181 253); }

.fsteps-hint {
  font-size: 0.8rem;
  opacity: 0.6;
}
.fsteps-hint b { font-weight: 600; }

/* Pushed to the far end: it is a reassurance, not an instruction. */
.fsteps-count {
  margin-left: auto;
  font-size: 0.8rem;
  opacity: 0.6;
  font-variant-numeric: tabular-nums;
}

/* The keyboard hint is a lie on a touch screen, where there is no Enter key. */
@media (hover: none) and (pointer: coarse) {
  .fsteps-hint { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .fsteps [data-step].from-next,
  .fsteps [data-step].from-back { animation: none; }
  .fsteps-bar i { transition: none; }
}
/* ---------------------------------------------------------------------------
 * The success check — one animation, three apps.
 *
 * Shown when something the person did actually landed: an event created, a
 * profile saved, a task ticked. It is a confirmation, not decoration, so it
 * only ever appears in response to a real result from the server.
 *
 * Four animations run on the wrapper at once and none of them fight, which is
 * the part worth understanding: the rotate owns `transform` and the bob uses
 * the standalone `translate` property. Had both used transform, the later
 * would silently replace the earlier — the trap that has bitten every animated
 * screen in this project. The path draw runs on the <path> itself.
 * ------------------------------------------------------------------------ */
.t-success-check {
  /* Local, not :root — three apps share this file and a global name here
     would be one more thing able to collide with a utility class. */
  --check-opacity-dur: 500ms;
  --check-rotate-dur:  500ms;
  --check-rotate-from: 80deg;
  --check-bob-dur:     500ms;
  --check-y-amount:    40px;
  --check-blur-dur:    500ms;
  --check-blur-from:   10px;
  --check-path-dur:    500ms;
  --check-path-delay:  80ms;
  --check-ease-out:     cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-opacity: cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-rotate:  cubic-bezier(0.22, 1, 0.36, 1);
  --check-ease-bob:     cubic-bezier(0.34, 1.35, 0.64, 1);
  --check-ease-path:    cubic-bezier(0.22, 1, 0.36, 1);

  display: inline-block;
  flex: none;
  transform-origin: center;
  opacity: 0;
  will-change: transform, opacity, filter;
}

/* overflow visible so the stroke is not clipped mid-draw; display block kills
   the inline whitespace that otherwise sits under an SVG. */
.t-success-check svg { display: block; overflow: visible; }

/*
   23 is ceil(getTotalLength()) for the tick below, measured rather than
   guessed — 22.312. Too small and the line never finishes drawing; too large
   and it starts already partly drawn. Re-measure if the path ever changes.
*/
.t-success-check svg path {
  stroke-dasharray: 23;
  stroke-dashoffset: 23;
}

.t-success-check[data-state="in"] {
  animation:
    t-check-fade   var(--check-opacity-dur) var(--check-ease-opacity) forwards,
    t-check-rotate var(--check-rotate-dur)  var(--check-ease-rotate)  forwards,
    t-check-blur   var(--check-blur-dur)    var(--check-ease-out)     forwards,
    t-check-bob    var(--check-bob-dur)     var(--check-ease-bob)     forwards;
}
.t-success-check[data-state="in"] svg path {
  animation: t-check-draw var(--check-path-dur) var(--check-ease-path)
             var(--check-path-delay) forwards;
}

@keyframes t-check-fade   { from { opacity: 0; }                                to { opacity: 1; } }
@keyframes t-check-rotate { from { transform: rotate(var(--check-rotate-from)); } to { transform: rotate(0deg); } }
@keyframes t-check-blur   { from { filter: blur(var(--check-blur-from)); }       to { filter: blur(0); } }
@keyframes t-check-bob    { from { translate: 0 var(--check-y-amount); }         to { translate: 0 0; } }
@keyframes t-check-draw   { to { stroke-dashoffset: 0; } }

/*
   Reduced motion still gets the confirmation, just without the movement —
   the tick is information, so removing it entirely would remove the answer to
   "did that work". !important because the shorthand above is more specific
   than a plain override.
*/
@media (prefers-reduced-motion: reduce) {
  .t-success-check { animation: none !important; opacity: 1; }
  .t-success-check svg path { animation: none !important; stroke-dashoffset: 0 !important; }
}

/* ---------------------------------------------------------------------------
 * [hidden] has to win.
 *
 * Tailwind's preflight hides it through a :where() selector carrying zero
 * specificity, so ANY class that sets display — .flex, .block, .grid, or a
 * component rule like .person-option — silently beats it, and el.hidden = true
 * does nothing while looking perfectly correct in the inspector. This has
 * caught me four separate times in this codebase: the list recipient filter,
 * the form stepper, its Back button, and the cleaning people search.
 * ------------------------------------------------------------------------ */
[hidden] { display: none !important; }

/*
 * Hold to delete.
 *
 * The fill is a real element behind the label rather than a background
 * gradient, because its width is driven from a rAF loop in
 * assets/hold-delete.js and a gradient stop cannot be animated that way
 * without repainting the whole button every frame.
 *
 * No transition on the width: the script paints every frame already, and a
 * transition on top would make releasing the button drain slowly instead of
 * snapping back — which reads as "still deleting".
 */
.hold-btn { -webkit-tap-highlight-color: transparent; touch-action: none; user-select: none; -webkit-user-select: none; }
.hold-fill {
  position: absolute; inset: 0 auto 0 0;
  width: 0;
  background: rgba(239, 68, 68, .30);
}
.hold-btn:disabled .hold-fill { width: 0; }

/* ---------------------------------------------------------------------------
   Uploading pictures to a signage group.

   The same orb, the same dim, and a line of text under it. An upload is the
   same kind of wait as a page load, so it gets the same face rather than a
   second spinner to learn — the only addition is the count, because uploads go
   one file at a time and "2 of 3" is what separates slow from stuck.

   Kept as its own element rather than borrowing dialog#loading: that one has a
   twelve-second failsafe that hides it, which is correct for a navigation and
   would pull the orb out from under a slow upload.
--------------------------------------------------------------------------- */
dialog#signage-upload {
  /* As above: the flex lives on [open], or this would be on screen always. */
  display: none;
  position: fixed;
  inset: 0;
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  margin: 0;
  padding: 0;
  border: 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 18px;
  background: rgba(9, 9, 11, 0.66);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  color: #fafafa;
}
dialog#signage-upload[open] { display: flex; }
dialog#signage-upload::backdrop { background: transparent; }

#signage-upload-note {
  margin: 0;
  max-width: 80vw;
  text-align: center;
  font-size: 13px;
  line-height: 1.4;
  color: #d4d4d8;
  /* Long file names are the norm here and a wrapped one must not push the orb
     off centre, so it truncates instead. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
#signage-upload-note:empty { display: none; }

/* ---------------------------------------------------------------------------
   A settings sheet — the dialog behind an "Edit" on a list.

   Centred, explicitly, on both axes. A modal <dialog> centres itself only
   while it is free to size itself; `w-full` with a `max-w-*` pins the width,
   and a tall sheet then sits high with a gap underneath.

   `!important`, and not for lack of trying. Measured in a harness against the
   real stylesheet: from this same declaration block `inset` and `max-height`
   both apply and `margin` does not, and the used margin stays at the user
   agent's 8px. The same `margin: auto` set inline, or through an ID selector,
   or with !important, centres it correctly every time. So the escalation is
   the fix that was proven to work rather than the one that reads best — it is
   one property on one component, and the comment is here so nobody spends the
   afternoon I did removing it.
--------------------------------------------------------------------------- */
dialog.sheet:modal {
  inset: 0;
  margin: auto !important;
  /* Never taller than the screen, so a long sheet scrolls inside its own body
     rather than running off the bottom where Save cannot be reached. */
  max-height: calc(100vh - 3rem);
}
@supports (height: 100dvh) {
  /* Phone browsers: the toolbar is counted out of dvh but not out of vh, and
     the difference is exactly the strip Save was hiding behind. */
  dialog.sheet:modal { max-height: calc(100dvh - 3rem); }
}

/* The no-JavaScript path renders the sheet inline with `open` and no top
   layer, where `:modal` does not match. It cannot be centred in the viewport
   there — it is in the flow — so it is centred in the column instead. */
dialog.sheet[open] {
  margin-left: auto;
  margin-right: auto;
}

/* ---------------------------------------------------------------------------
   The email preview, when <dialog> has no showModal().

   `assets/email-log.js` falls back to setting the `open` attribute, which
   shows the panel with NO top layer — so `:modal` does not match, `.sheet`
   above does nothing, and the element renders in the flow, down at the end of
   the list where it was written. Centring it needs saying outright.

   Not written as `:not(:modal)`: a browser old enough to be on this path may
   not know the `:modal` selector either, and one unknown selector drops the
   whole rule. The class is added by the same branch that sets the attribute,
   so the two can never disagree.

   Ian, 2026-09-21: "the modal for the emails also went to the top can we have
   it centered on the screen". I could NOT reproduce that in Chrome at any
   viewport — modal dialogs centred correctly every time, with and without long
   messages — so this covers the one path that provably would not, rather than
   claiming to be the fix.
--------------------------------------------------------------------------- */
#email-preview.is-inline {
  position: fixed;
  inset: 0;
  margin: auto;
  z-index: 60;
  max-height: calc(100vh - 3rem);
  box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.7);
}
@supports (height: 100dvh) {
  #email-preview.is-inline { max-height: calc(100dvh - 3rem); }
}

/* ---------------------------------------------------------------------------
   THE EMAIL PREVIEW SIZES ITSELF TO THE WINDOW
   ---------------------------------------------------------------------------
   Ian, 2026-10-01: "its a little too small and basic… I need it to be larger so
   i can view the full email without having to scroll so much lets make the
   modal responsive to screen size."

   It was `max-w-2xl` — 672px — with the card's body capped at 60vh and the
   message iframe pinned at 420px inside that. On a 1440px screen that is a
   small box, in a small card, in a quarter of the window, and two nested
   scrollbars to read one email.

   `min()` so it is as wide as the window allows and never wider: 94vw on a
   laptop, capped at 1100px where a line of email text stops being readable.
   `max-width: none` because the Tailwind `max-w-2xl` is still on the element
   and would otherwise win.
--------------------------------------------------------------------------- */
#email-preview.is-inline {
  width: min(1100px, 94vw);
  max-width: none;
  /*
   * Or the width is CONTENT only and the dialog's own border and padding sit
   * outside it — 94vw came out 391px wide in a 375px window, hanging 16px off
   * the right edge. A <dialog> carries a UA border and 1em of padding that
   * Tailwind's preflight does not reach here.
   */
  box-sizing: border-box;
}

/*
 * The card is a column: a fixed head, a body that takes what is left, and a
 * fixed foot. `min-height: 0` on the body is the load-bearing line — a flex
 * child refuses to shrink below its content without it, so the body would
 * grow to fit the whole email and push the Close button off the bottom
 * instead of scrolling.
 */
#email-preview.is-inline > div {
  display: flex;
  flex-direction: column;
  /*
   * A DEFINITE height, not a max.
   *
   * With `max-height` the card shrinks to its content, so the body has no
   * definite height, so the frame's `height: 100%` has nothing to resolve
   * against and falls back to its minimum — the dialog came out 534px tall in
   * a 1000px window, which is the complaint all over again. A percentage
   * height needs a parent that has already decided.
   *
   * Capped at 56rem so a 4K monitor does not produce a dialog the height of
   * the wall for a three-line message.
   */
  height: min(calc(100dvh - 3rem), 56rem);
}
.email-card-head,
.email-card-foot { flex: 0 0 auto; }
.email-card-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* A column too, so the frame can take what is left after the plain-text
     toggle rather than being 100% of the body and overflowing it by exactly
     the height of that toggle. */
  display: flex;
  flex-direction: column;
}

/*
 * The message fills the body rather than scrolling inside it, which leaves
 * ONE scroll area instead of two. A minimum so a short email in a short window
 * still gets a usable box.
 */
.email-card-frame {
  /* `flex`, not `height: 100%` — a percentage is of the WHOLE body and ignores
     the toggle underneath it, which is what put a scrollbar on a card that had
     nothing to scroll. */
  flex: 1 1 auto;
  min-height: 16rem;
}

/* On its own page there is no dialog to fill, so the frame takes a share of
   the window instead of collapsing to its minimum. */
#email-preview:not(.is-inline) .email-card-frame,
body:not(:has(#email-preview.is-inline)) .email-card-frame {
  height: calc(100dvh - 20rem);
}

/* ---------------------------------------------------------------------------
   The signage loop — destructive controls that stay out of the way.

   Every moment used to carry five "Remove …" buttons in a row underneath it,
   which on a six-slide loop is thirty red-edged buttons and nothing else
   competing. The controls are the same; they are just quiet until wanted.

   Not `display: none` — opacity keeps them in the layout, so revealing one
   never shifts the row under the pointer.
--------------------------------------------------------------------------- */
.sg-row .sg-x {
  opacity: 0;
  transition: opacity .12s ease;
}
.sg-row:hover .sg-x,
.sg-row:focus-within .sg-x {
  opacity: 1;
}

/* A touch screen has no hover, and :focus-within only lands after a tap that
   was meant to do something else. On touch they are simply always there. */
@media (hover: none) {
  .sg-row .sg-x { opacity: 1; }
}

/* Somebody navigating by keyboard must never chase an invisible control. */
.sg-row .sg-x:focus-visible { opacity: 1; }

/* ---------------------------------------------------------------------------
   ONE BOX MODEL FOR EVERY BUTTON
   ---------------------------------------------------------------------------
   Ian, 2026-09-30: "make sure all buttons are aligned same size etc."

   Hub has no component layer — every button is an <a> or a <button> with its
   utilities typed out again — so nothing kept them in step, and measuring them
   found two ways the same button came out two different sizes.

   1. A BORDER CHANGES THE HEIGHT. An outlined button carries `border`, a
      filled one does not, and Tailwind's preflight sets border-width: 0. Same
      px-3 py-2 text-sm, same 20px line box, and they measure 38px and 36px.
      Cancel beside Save, two pixels apart, in 29 templates.

   2. A LINK IS AN INLINE BOX. 31 button-like <a> elements carry no display
      class, and vertical padding on an inline box does not set its height —
      so they came out ~3px shorter than the <button> next to them and sat off
      its centre line.

   `:where()` on purpose: it has ZERO specificity, so every one of these is a
   FLOOR that any utility class still beats. `border-zinc-700` keeps its colour
   (verified: zinc-700 and zinc-800 both survive), `block` and `flex` keep their
   display, and an element that already declared a border is untouched. Nothing
   here can win an argument with a template.

   Why not edit the templates: 78 filled controls and 31 links, none of which
   can be rendered on a machine with no PHP. One rule that cannot override
   anything is a smaller risk than a hundred hand edits that cannot be checked.

   An explicit height stays exact — box-sizing is border-box, so h-7 w-7 is
   still 28x28, and the transparent border sits inside it.
--------------------------------------------------------------------------- */
:where(button, [type="submit"], [type="button"], [type="reset"], a[class*="rounded"]) {
  border-width: 1px;
  border-style: solid;
  border-color: transparent;
}

:where(a[class*="rounded"][class*="px-"]) {
  display: inline-flex;
  align-items: center;
}

/* ===========================================================================
   DESKTOP POLISH — the dashboard, 2026-09-30
   ---------------------------------------------------------------------------
   Ian: "up the game on the desktop version… the homepage make it more nicer
   looking, add a gradient background."

   Written here rather than as utility classes because HUB HAS NO TAILWIND
   BUILD: a class that is not already in the shipped tailwind.css does nothing
   and says nothing, so `bg-gradient-to-br` and friends are not available. CSS
   in this file always works, and it is the right tool for a gradient anyway.
   =========================================================================== */

/*
 * Three soft glows over the flat ground.
 *
 * `background-image`, NOT the `background` shorthand. The page's colour comes
 * from `bg-zinc-950`, a class on <body> (specificity 0,1,0) which a `body`
 * element selector (0,0,1) could never beat. Setting a different PROPERTY
 * sidesteps the argument entirely: the colour stays as the base layer and
 * these paint on top. The shorthand would reset the colour and lose it.
 *
 * `fixed`, so the glow belongs to the window rather than the document and does
 * not slide away down a long page.
 *
 * Violet is Hub's accent — see mymdv-hub-accent; the emerald that fills these
 * templates is a tint on cards, not the brand. The sky glow is there to keep
 * the top from reading as one flat wash of purple, and the third sits bottom
 * right so the lower half of a tall screen is not dead black under a lit top.
 */
body {
  background-image:
    radial-gradient(ellipse 90% 55% at 12% -12%, rgba(139, 92, 246, 0.14), transparent 62%),
    radial-gradient(ellipse 70% 50% at 88% 2%,   rgba(56, 189, 248, 0.07), transparent 58%),
    radial-gradient(ellipse 90% 65% at 80% 112%, rgba(139, 92, 246, 0.11), transparent 62%);
  background-attachment: fixed;
  background-repeat: no-repeat;
}

/*
 * The greeting. It was `text-xl` — 20px — which reads as a field label on a
 * 1440px screen rather than as the first thing on the landing page.
 */
.dash-greeting {
  font-size: 1.5rem;
  line-height: 1.15;
  letter-spacing: -0.02em;
}
@media (min-width: 1024px) {
  .dash-greeting { font-size: 1.875rem; }
}

/* The name in the accent, so "good morning" is the sentence and the person is
   the subject. violet-300: violet-400 is the button colour and would read as
   something to click. */
.dash-greeting-name { color: #c4b5fd; }

/*
 * Cards sat flat on the page, which on a dark ground makes them read as holes
 * cut out of it rather than objects on it. A hairline highlight along the top
 * edge and a real shadow underneath is the whole trick.
 */
.dash-card {
  background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0) 45%);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 12px 28px -16px rgba(0, 0, 0, 0.75);
  transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
}
.dash-card:hover {
  transform: translateY(-2px);
  border-color: #3f3f46;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 18px 36px -18px rgba(0, 0, 0, 0.85);
}

/* Somebody who asked for less movement keeps the depth and loses the lift. */
@media (prefers-reduced-motion: reduce) {
  .dash-card { transition: none; }
  .dash-card:hover { transform: none; }
}

/* ---------------------------------------------------------------------------
   THE DESKTOP DASHBOARD — a bento above 1024px
   ---------------------------------------------------------------------------
   Ian picked this from three mockups on 2026-09-30, after two earlier attempts
   at a borderless column layout that he kept finding wrong — rightly: two
   numbers and three short lists cannot fill a 2560px screen as columns of
   text, so every version either stranded the content in a corner or stretched
   the dividers hundreds of pixels from what they divided.

   Tiles solve that where columns could not, because a tile is allowed to be a
   different SIZE. The boxes come back, but no two are the same: the numbers
   get wide short tiles, the events list — which has a date, a title, a time
   and a room on every row — gets a tall double-width one, and the two name
   lists get narrow tall ones beside it.

   What was wrong with the original was never that it had boxes. It was that it
   had five identical ones, so a single figure was framed exactly like a
   five-person list.

   MOBILE IS UNTOUCHED: all of this is inside the media query, and the markup
   is the same at every width.
--------------------------------------------------------------------------- */
@media (min-width: 1024px) {
  .dash-grid {
    display: grid !important;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1rem;
    /* Left-anchored, never centred: `max-w-[1600px]` on the page carries
       `mx-auto`, which pulled the greeting away from the sidebar's edge and
       left an uneven margin either side. A cap that does not centre is the
       thing that was wanted. */
    max-width: 104rem;
  }

  /*
   * Order, because DOCUMENT order is not tile order.
   *
   * The template emits giving, Newest Members, events, workflow, birthdays —
   * sensible as markup, wrong as a bento: auto-placement put a tall narrow
   * list beside the first number, which left a one-column hole the wide events
   * tile could not fit into, and the whole thing came out ragged.
   *
   * Numbers first, then the wide events tile, then the two narrow lists. This
   * is a visual reordering only; the DOM keeps the order that makes sense to a
   * screen reader and to the phone.
   */
  .dash-card--stat    { order: 1; }
  .dash-card--feature { order: 2; grid-column: span 2; }
  .dash-card--list    { order: 3; }

  /* A safety net for the permission cases: with a widget missing, `dense`
     backfills the hole left behind instead of leaving a gap in the middle. */
  .dash-grid { grid-auto-flow: row dense; }
}

/* Four columns once there is room for them — 1440px, not 1280px. At 1280 the
   sidebar and padding leave about 1008px, so four columns come out 240px wide
   and a name like "Katherine Agudelo" sits in 200px of usable space with its
   avatar. Below this the bento is a simple two-up. */
@media (min-width: 1440px) {
  .dash-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }

  .dash-card--stat    { grid-column: span 2; }
  .dash-card--feature { grid-column: span 2; grid-row: span 2; }
  .dash-card--list    { grid-column: span 1; grid-row: span 2; }

  /*
   * The narrow list tiles are narrow BECAUSE the events tile is beside them.
   * Somebody without calendar access has no events tile, and the two lists
   * were left sitting in half a row — 800px of tiles in a 1647px grid, with
   * the rest a hole.
   *
   * `:has()` asks the grid whether that tile exists at all, which is the only
   * honest way to decide: the alternative is counting permissions in PHP and
   * passing the answer in, which is what the previous layout did and what made
   * it brittle. Where `:has()` is not supported this rule is skipped and the
   * tiles stay narrow — the old behaviour, not a broken one.
   */
  .dash-grid:not(:has(.dash-card--feature)) .dash-card--list { grid-column: span 2; }

  /*
   * The same question about the other pair. Two numbers share the top row at
   * half each; ONE number left the other half a hole — 81% of the grid covered
   * instead of 96%.
   *
   * `:has(.dash-card--stat ~ .dash-card--stat)` is "is there a stat with
   * another stat before it", i.e. are there at least two. Negated, it is "at
   * most one", and that one takes the whole row.
   */
  .dash-grid:not(:has(.dash-card--stat ~ .dash-card--stat)) .dash-card--stat {
    grid-column: span 4;
  }

  /* And the last of the three: the events tile is double-width because two
     narrow lists sit beside it. With no lists there is nothing to sit beside,
     so it takes the row rather than leaving half of one empty. */
  .dash-grid:not(:has(.dash-card--list)) .dash-card--feature { grid-column: span 4; }
}

@media (min-width: 1024px) {
  /* Headings recede to labels so the content leads. Kept from the column
     version — it was the part of it that worked. */
  .dash-grid .dash-card h2 {
    font-size: 11px !important;
    letter-spacing: 0.09em;
    text-transform: uppercase;
    color: #a1a1aa !important;
  }
  .dash-grid .dash-card > div:first-child > span:first-child {
    height: 1.5rem !important;
    width: 1.5rem !important;
  }

  /* The number is the point of a stat, so it gets to be a number. */
  .dash-card--stat p.text-3xl {
    font-size: 3rem;
    line-height: 1;
    letter-spacing: -0.03em;
  }

  /* One stamp, in the header, rather than the same string on all five tiles. */
  .dash-stamp { display: none; }
}

/* Below 1024px the header stamp is the duplicate, so it is the one that goes. */
@media (max-width: 1023.98px) {
  .dash-stamp-header { display: none; }
}

/* ---------------------------------------------------------------------------
   HOUSEHOLD CARDS
   ---------------------------------------------------------------------------
   Ian, 2026-09-30: "can we have them seperated into boxes and it shows each
   members profile picture?"

   Written here because the three utilities this wanted — `-space-x-2`,
   `ring-zinc-900` and `2xl:grid-cols-4` — are NONE of them in the shipped
   tailwind.css, and a class that is not there does nothing and says nothing.
   Hub has no Tailwind build; this file is the only reliable place for anything
   the original build did not happen to include.
--------------------------------------------------------------------------- */

/* A fourth column once there is room. `2xl:grid-cols-5` is the only 2xl grid
   the build shipped, and five household cards to a row is too many to read. */
@media (min-width: 1536px) {
  .hh-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* The overlapping stack. The ring is the CARD's own background, so circles
   that overlap read as separate faces rather than one blob — and it is a
   box-shadow rather than a border so it costs the avatar no size. */
.hh-faces > * + * { margin-left: -0.5rem; }
.hh-face { box-shadow: 0 0 0 2px #18181b; }


/* ---------------------------------------------------------------------------
   UTILITIES THE SHIPPED TAILWIND BUILD DOES NOT CONTAIN
   ---------------------------------------------------------------------------
   Hub has no Tailwind build, so a utility the original compile did not happen
   to include does nothing and says nothing. These six are used in templates
   today and have been doing nothing — found on 2026-10-01 once classcheck was
   scanning every template instead of only the files it was handed.

   Values copied from the build's own output, not guessed: amber-400 is
   rgb(251 191 36), violet-400 is rgb(167 139 250).

   Written at the SAME specificity Tailwind uses (a single class), so anything
   that already beat them keeps beating them.
--------------------------------------------------------------------------- */

/* signage/group.php, signage/index.php — a flex item that must not grow or
   shrink. Without it those items were free to be squeezed by their siblings. */
.flex-none { flex: none; }

/* signage/directory.php, signage/screens.php — the hover state on a light row. */
.hover\:bg-white:hover { background-color: rgb(255 255 255); }

/* signage/directory.php — an arbitrary value, which a build only emits if it
   saw that exact string when it ran. */
.min-w-\[9rem\] { min-width: 9rem; }

/* admin/accounts.php — the tinted role chips. The build shipped the solid
   colours but not these three opacity variants, so the chips had no fill and
   no border at all. */
.bg-amber-400\/10  { background-color: rgb(251 191 36 / 0.1); }
.bg-violet-400\/10 { background-color: rgb(167 139 250 / 0.1); }
.border-violet-400\/40 { border-color: rgb(167 139 250 / 0.4); }

/* ---------------------------------------------------------------------------
   NOTE on rgb(141 141 151)
   ---------------------------------------------------------------------------
   That is zinc-500. It moved from #71717a on 2026-10-07: Tailwind's value is
   3.6:1 on zinc-900 and small text needs 4.5. The palette change is in
   design/tailwind.config.js, but every hand-written copy in THIS file froze
   the old number and had to be swept with it — which is the cost of writing a
   palette value out by hand, and the reason these now carry the name too.
   --------------------------------------------------------------------------- */

/* ---------------------------------------------------------------------------
   The compose preview — /app/updates and /announcements
   ---------------------------------------------------------------------------
   A small copy of the MEMBERS APP sitting inside Hub's dark admin chrome, so
   whoever is writing can see what lands on a phone.

   `.pv` redeclares the members app's tokens rather than inheriting Hub's.
   That is the whole trick: everything inside is written against --ink,
   --accent, --sky-top and friends exactly as src/index.css writes it, so the
   rules below can be read beside the originals and compared. Hub's own
   violet must NOT reach in here — the app's accent is blue, and a preview
   painted in the wrong accent is a preview of something that does not exist.

   Values copied from the members app's :root, light theme. The app's dark
   theme is not reproduced: the shapes are identical and the point of this
   panel is layout and length, not which of the two grounds a given member
   has chosen. The caption under the panel says so.
   --------------------------------------------------------------------------- */
.pv {
  --ink:        #0c1a2b;
  --ink-soft:   #4a5c73;
  --ink-faint:  #8394a8;
  --sky-top:    #dbe6f4;
  --sky-bottom: #f3f6fa;
  --surface:    #ffffff;
  --surface-2:  #eaf0f7;
  --hairline:   rgba(12, 26, 43, 0.10);
  --accent:     #1668cc;
  --accent-ink: #ffffff;

  /* The phone's own font, not Hub's. A line that fits here in Inter and
     wraps there in the app's face is exactly the thing this panel exists to
     show. */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  border-radius: 18px;
  overflow: hidden;
  background: var(--sky-bottom);
  /* A rim, so the white sheet does not bleed into Hub's zinc-900 card. */
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .08), 0 10px 30px rgba(0, 0, 0, .35);
}

/* --- the pop-up ---------------------------------------------------------- */

/* .up-sheet's own radius is 22px 22px 0 0 because it rises from the bottom of
   a phone. Here it is the whole panel, so the corners come from .pv and this
   only has to supply the ground. */
.pv-sheet { background: var(--sky-bottom); }

/* .up-art, at the width this panel actually gets. The height is the app's
   150px scaled by roughly the same factor as the width (320 against 520), so
   the band keeps its proportion instead of becoming a stripe. */
.pv-art {
  position: relative;
  display: grid; place-items: center;
  height: 96px;
  overflow: hidden;
  background: var(--sky-top);
  background: color-mix(in srgb, var(--accent) 7%, var(--sky-top));
}

/* The rings. Not animated here: the app drifts them over 33 seconds, which
   nobody watching a form would ever see move, and a preview that moves pulls
   the eye away from the words being typed. */
.pv-rings {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  color: var(--accent);
  pointer-events: none;
}

/* `.pv-icon`, NOT `.pv-art svg`. The rings are an svg inside .pv-art too, and
   a descendant selector (0-1-1) would out-rank the class (0-1-0) and size the
   whole contour drawing to 38px — the same collision that shipped in the app
   and was found with getBoundingClientRect. One class each, no exceptions. */
.pv-icon {
  position: relative;      /* above the rings */
  width: 38px; height: 38px;
  color: var(--accent);
  opacity: .92;
}

.pv-body { padding: 14px 16px 16px; }
.pv-eyebrow {
  display: block; margin-bottom: 4px;
  font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
  color: var(--accent);
}
.pv-body b {
  display: block;
  font-size: 15px; font-weight: 800; letter-spacing: -.022em; line-height: 1.3;
  color: var(--ink); text-wrap: balance;
}
.pv-body p {
  margin: 5px 0 0;
  font-size: 12.5px; line-height: 1.5; color: var(--ink-soft);
}
/* The app's primary button, as a label: nothing here is clickable, and a
   preview with a working-looking button invites somebody to press it. */
.pv-btn {
  display: block; margin-top: 13px;
  border-radius: 13px; padding: 9px 14px;
  background: var(--accent); color: var(--accent-ink);
  font-size: 13px; font-weight: 700; text-align: center;
}

/* --- the notification row ------------------------------------------------ */

.pv-card { background: var(--surface); }
.pv-row {
  display: flex; align-items: flex-start; gap: 10px;
  padding: 12px 14px;
  /* .not-item.is-new — an unread row carries the accent wash, and an
     announcement that has just arrived is always unread. */
  background: color-mix(in srgb, var(--accent) 7%, transparent);
}
/* .not-icon, at 19px of glyph in a 32px tile. */
.pv-kind {
  flex: none;
  display: grid; place-items: center;
  width: 32px; height: 32px;
  border-radius: 11px;
  color: var(--accent);
  background: var(--surface-2);
  background: color-mix(in srgb, currentColor 13%, transparent);
}
.pv-kind svg { width: 17px; height: 17px; }
.pv-rowtext { min-width: 0; flex: 1; }
.pv-rowtext b {
  display: block;
  font-size: 13.5px; font-weight: 700; letter-spacing: -.01em; line-height: 1.32;
  color: var(--ink);
}
.pv-rowtext p {
  margin: 3px 0 0;
  font-size: 12.5px; line-height: 1.45; color: var(--ink-soft);
}
.pv-rowtext small {
  display: block; margin-top: 4px;
  font-size: 11px; color: var(--ink-faint);
}

/* .not-pic — an announcement's picture. The app caps the HEIGHT and crops to
   fill, so a tall flyer arrives as a wide band with its middle showing; that
   surprises people, which is the reason this is previewed at all.

   160px, not the app's 220: the crop depends on the box's PROPORTION, and
   this card's picture column is about 266px wide against roughly 360 on a
   phone. 220 x (266/360) is 162. Copying the 220 across would have shown a
   squarer picture than the member gets and hidden the very thing being
   checked. */
.pv-pic {
  display: block;
  width: 100%; max-height: 160px;
  margin-top: 8px;
  border-radius: 11px;
  object-fit: cover;
  background: var(--surface-2);
}

/* .not-go — the button under an announcement that carries a link. A label
   here, not a control: nothing in a preview should look pressable. */
.pv-go {
  display: inline-block;
  margin-top: 8px;
  padding: 6px 11px;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  background: var(--surface);
  font-size: 12px; font-weight: 600; color: var(--ink);
}

/* An empty field shows its own placeholder, greyed, so the card keeps its
   height from the first keystroke instead of growing a line at a time. */
.pv-ghost { color: var(--ink-faint) !important; font-weight: 500 !important; }

/* ---------------------------------------------------------------------------
   The cleaning rota: both views at once on a wide screen
   ---------------------------------------------------------------------------
   Ian, 2026-10-07: "maybe have the calendar view on the left side for
   desktop". Below xl the List/Calendar toggle still swaps them; from xl up
   they sit side by side and the toggle is hidden.

   Two things here are load-bearing:

   1. `[hidden]` has to be BEATEN, not removed. The panels keep their
      attribute so the toggle below xl goes on working untouched, and
      app.css ends with `[hidden]{display:none!important}` — so the override
      needs both `!important` AND higher specificity. `.cleaning-views >
      [data-panel][hidden]` is (0,3,0) against that rule's (0,1,0); drop the
      `[data-panel]` and it loses silently.

   2. The CALENDAR is placed in column 1 by `grid-column`, not by moving it
      in the markup. The list stays first in the document, so the phone and
      a screen reader get the thing you came for and only the wide layout
      puts the month beside it. Same rule .dash-grid follows.

   42rem for the calendar: its month grid carries `min-w-[640px]`, so a
   narrower column would make it scroll sideways inside itself — worse than
   not showing it at all.
   --------------------------------------------------------------------------- */
@media (min-width: 1280px) {
  .cleaning-views {
    display: grid;
    grid-template-columns: 42rem minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
  }
  .cleaning-views > [data-panel][hidden] { display: block !important; }
  .cleaning-views > [data-panel="calendar"] { grid-column: 1; grid-row: 1; }
  .cleaning-views > [data-panel="list"]     { grid-column: 2; grid-row: 1; }
}

/* The overlapping face stack, shared with the household cards.
   One rule, two entry points: `.hh-faces` was named for the page it was
   written on, and the rota needs the same thing. Writing it twice is how the
   two drift apart. `ring-zinc-900` and `-space-x-2` are both absent from the
   shipped tailwind.css, which is why this is CSS and not utilities. */
.faces > * + * { margin-left: -0.5rem; }
.face { box-shadow: 0 0 0 2px #18181b; }

/* ---------------------------------------------------------------------------
   The tab bar's More sheet: capped and scrollable
   ---------------------------------------------------------------------------
   The sheet is `position: fixed` and grows UPWARD from the bar. With Hub's
   thirteen overflow entries it is 845px tall, so on a 375x812 phone its top
   sat at -121px — Forms and Prayer off the screen — and on a 375x667 phone at
   -266px, taking Cleaning with them. Nothing scrolled, so those entries could
   not be reached at all.

   `dvh` BEFORE the fallback matters on iOS: `vh` is measured against the
   chrome-HIDDEN height, so it over-estimates the visible area and the cap
   would still let the sheet run off the top while the address bar is showing.
   Same trap the kiosk bubbles hit. `vh` stays as the first declaration for
   anything too old to know `dvh`.

   The subtraction is the sheet's own 5.5rem offset from the bottom, both
   safe-area insets, and 1rem so it never kisses the status bar.

   `overscroll-behavior: contain` so flicking the end of the list scrolls the
   list and not the page behind it.
   --------------------------------------------------------------------------- */
.more-sheet {
  max-height: calc(100vh  - 5.5rem - env(safe-area-inset-bottom) - env(safe-area-inset-top) - 1rem);
  max-height: calc(100dvh - 5.5rem - env(safe-area-inset-bottom) - env(safe-area-inset-top) - 1rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

/* ---------------------------------------------------------------------------
   The photo cropper (assets/crop.js)
   ---------------------------------------------------------------------------

   Written here rather than as Tailwind utilities because the markup is built
   in JavaScript, and Tailwind only ships the classes it finds in templates —
   a utility used only from a .js file is a class that is not in the build and
   therefore does nothing. `classcheck.py` scans templates for the same
   reason, so it would not have caught it either.
*/
.cropper {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgb(0 0 0 / 0.6);
}
.cropper-box {
  width: 100%;
  max-width: 22rem;
  padding: 1.25rem;
  border: 1px solid rgb(63 63 70);
  border-radius: 0.75rem;
  background: rgb(24 24 27);
  box-shadow: 0 20px 50px rgb(0 0 0 / 0.5);
}
.cropper-head { font-size: 1rem; font-weight: 600; color: rgb(244 244 245); }
.cropper-sub  { margin: 0.25rem 0 1rem; font-size: 0.875rem; color: rgb(161 161 170); }

/* `touch-action: none` is load-bearing: without it the browser claims the
   gesture for scrolling before the first pointermove arrives, and the drag
   never starts on the iPad this is mostly used from. */
.cropper-frame {
  position: relative;
  width: 260px;
  height: 260px;
  margin: 0 auto;
  overflow: hidden;
  border: 1px solid rgb(63 63 70);
  border-radius: 9999px;
  background: rgb(39 39 42);
  cursor: grab;
  touch-action: none;
}
.cropper-frame:active { cursor: grabbing; }
/* `max-width: none` or the app's own img rule shrinks it back to the frame
   and the zoom does nothing. */
.cropper-img { position: absolute; max-width: none; user-select: none; }

.cropper-zoom {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 1rem;
}
/* The border is not decoration. Tailwind's preflight resets every button to
   a transparent background with no border, so a rule that styles only :hover
   leaves a bare glyph sitting next to the slider at rest — it reads as a
   label, not a control. Matched to .cropper-cancel so the two kinds of
   secondary button in this panel are the same object. */
.cropper-step {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border: 1px solid rgb(63 63 70);
  border-radius: 0.375rem;
  color: rgb(212 212 216);
  font-size: 1.125rem;
  line-height: 1;
}
.cropper-step:hover { background: rgb(39 39 42); color: rgb(244 244 245); }
.cropper-range { flex: 1; accent-color: rgb(167 139 250); cursor: pointer; }

.cropper-foot {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
  margin-top: 1.25rem;
}
/*
   Hub has no `.btn` component class — every button in a template is a string
   of Tailwind utilities. This markup is built in JavaScript, where those
   utilities are not available, so the two buttons are styled properly here.
   Matched to the violet primary the templates use.
*/
.cropper-cancel,
.cropper-use {
  border-radius: 0.375rem;
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
}
.cropper-cancel {
  border: 1px solid rgb(63 63 70);
  color: rgb(228 228 231);
}
.cropper-cancel:hover { background: rgb(39 39 42); }
.cropper-use {
  background: rgb(167 139 250);
  color: rgb(9 9 11);
}
.cropper-use:hover { background: rgb(196 181 253); }
.cropper-use:disabled { opacity: 0.6; }
