Files
HealthLog/src/app/globals.css
T
Marc-André Bombeck ef1976832b refactor(css): move the shell and sheet rules into the components layer
Unlayered, the safe-area, keyboard and sheet-clearance rules outranked
every utility on the same element, so a later bg-* on the shell or a
bottom-* on a sheet would have been ignored without a trace. They now
sit under the utilities. The bottom sheet drops its bottom-0, which the
layered keyboard rule replaces with the same zero until the keyboard
opens; the keyboard rules that must win keep their !important. The
toast lift stays unlayered to outrank the toaster's own rules.
2026-10-08 22:57:47 +02:00

1305 lines
50 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@import "tailwindcss" source("../..");
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/*
* Exclude planning + docs directories from Tailwind v4's content scan.
*
* v4's scanner walks the entire project root by default and picks any
* class-shaped token out of every text file. Planning reports and
* design docs often quote literal class-name fragments such as
* `bg-[var(...)]/12` as examples, and the bracketed-CSS-variable form
* combined with an opacity modifier generates an escaped selector that
* Turbopack's CSS parser rejects with `Unexpected token Delim('.')`,
* 500-ing every page. Constraining the scan to source code avoids
* fixture-style strings poisoning the generated CSS.
*
* The `source("../..")` argument on the `@import "tailwindcss"` line
* above pins auto-detection to the project root (resolved from this
* stylesheet) instead of letting Tailwind walk up from whatever cwd
* the build tool happens to be sitting at. That mirrors the
* cwd-anchored fix the safety-contracts loader landed in
* commit `dd44a0d` and stops the planning / docs directories from
* leaking class-shaped fragments into the generated CSS regardless of
* whether the build runs from the repo root or a tooling subdir.
*
* The `@source not` directives below are resolved relative to this
* stylesheet, so they point at `<repo-root>/.planning` and
* `<repo-root>/docs`.
*/
@source not "../../.planning";
@source not "../../docs";
@custom-variant dark (&:is(.dark *));
/*
* `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields
* take the compact 14 px type only under it: iOS and iPadOS Safari zoom the
* page into any focused field whose text is smaller than 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* (844 px) and to every iPad. Touch-first screens keep the 16 px base at
* every width.
*/
@custom-variant pointer-fine {
@media (hover: hover) and (pointer: fine) {
@slot;
}
}
/*
* `pointer-coarse:` — a finger is the primary pointer. The control
* primitives (Button, tab triggers, the sidebar rail rows) grow their hit
* area to the 44 px touch floor under it, so a phone or a touch tablet gets
* reachable targets while a mouse keeps the compact desktop density.
*/
@custom-variant pointer-coarse {
@media (pointer: coarse) {
@slot;
}
}
/*
* `shell-desktop:` / `shell-mobile:` — which app shell the viewport gets.
* The desktop rail (sidebar, no bottom nav) from `md` up, EXCEPT a touch
* screen at most 520 px tall: a phone held sideways is 844–932 px wide but
* only 390–430 px tall, and the rail plus the top bar left it a sliver of
* content and no thumb-reachable navigation. It keeps the phone shell (top
* bar, bottom nav, capture button) instead. Everything that switches
* between the two shells keys on these, never on a bare `md:`, so the bars
* cannot disagree about which shell is showing.
*/
@custom-variant shell-desktop {
@media (min-width: 48rem) and (pointer: fine),
(min-width: 48rem) and (pointer: none),
(min-width: 48rem) and (min-height: 521px) {
@slot;
}
}
/*
* `keyboard-open:` — while the on-screen keyboard is up (see the keyboard
* rules further down). A bottom sheet then stands on the keyboard, not on
* the home indicator, so its safe-area padding has nothing left to clear.
*/
@custom-variant keyboard-open (html[data-keyboard="open"] &);
@custom-variant shell-mobile {
@media (max-width: 47.98rem), (pointer: coarse) and (max-height: 520px) {
@slot;
}
}
@theme inline {
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
default scale starts at sm (640px), which leaves a gap between an iPhone SE
and a small tablet where two-up grids of fixed-width content overflow. */
--breakpoint-xs: 30rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-switch-off: var(--switch-off);
--color-primary-disabled: var(--primary-disabled);
--color-primary-disabled-foreground: var(--primary-disabled-foreground);
--color-input-focus: var(--input-focus);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-tab-inactive: var(--tab-inactive);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* The one sanctioned step below `text-xs` (12px). Minted so badges,
tag chips, and tabular-nums micro-labels stop hand-rolling
`text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a
third of a pixel per site. UI-STANDARDS §5 pins the usage: never for
muted body or meta text (the muted floor stays `text-xs`) — only for
chips, badges, and tabular micro-labels with verified contrast. */
--text-2xs: 0.6875rem;
--text-2xs--line-height: 1rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* Dracula semantic colors for charts */
--color-dracula-purple: var(--dracula-purple);
--color-dracula-pink: var(--dracula-pink);
--color-dracula-green: var(--dracula-green);
--color-dracula-cyan: var(--dracula-cyan);
--color-dracula-orange: var(--dracula-orange);
--color-dracula-red: var(--dracula-red);
--color-dracula-yellow: var(--dracula-yellow);
--color-dracula-lavender: var(--dracula-lavender);
/*
* Semantic feedback tokens — aliases over the Dracula palette so badges,
* banners, and status pills can stop hard-coding `text-dracula-green` /
* `bg-dracula-orange/15` and ask for the *meaning* instead. Each is a
* straight alias of the existing colour at the time of introduction so
* the visual stays pixel-identical to v1.4.6 — what changes is intent.
* - success → dracula-green (positive outcome, e.g. published)
* - warning → dracula-orange (caution / non-blocking attention)
* - info → dracula-cyan (neutral informational note)
*/
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-info: var(--info);
/*
* Brand accent tokens — identity colours with no semantic (status)
* twin. `--brand-pink` is the Coach-gradient / Apple-Health accent
* (`from-primary to-brand-pink`, the iOS-source badge, the recording
* mic). Per-theme values ride the `--dracula-pink` primitive, which
* the light theme overrides to its AA-tuned counterpart:
* dark #ff79c6 — 6.86:1 card / 5.97:1 bg; light #a3134d — 7.32:1
* card / 6.83:1 bg (WCAG relative-luminance, computed).
*/
--color-brand-pink: var(--brand-pink);
/*
* Status-pill text tokens. The target status pill paints coloured text
* on a 10% same-hue tint; the raw Dracula green/orange/red would fail
* WCAG AA (4.5:1) for the "out" tone on the muted reference panel in
* dark mode and for every tone on a white card in light mode. These
* theme-aware tokens land each tone at or above AA on both `bg-card`
* and `bg-muted/30` in both themes while keeping the Dracula feel.
*/
--color-status-pill-in: var(--status-pill-in);
--color-status-pill-near: var(--status-pill-near);
--color-status-pill-out: var(--status-pill-out);
/*
* Dose-accent token. The medication cards stamp the upcoming schedule
* dose in purple. They previously hard-coded Tailwind stock
* `text-purple-400` (`oklch(0.714 0.203 305.504)`), which reads ~3.1:1 on
* the white Alucard card and fails WCAG AA (4.5:1) for body text. This
* theme-aware token keeps the exact dark-mode purple and swaps to a darker
* AA-safe purple on the light card — mirroring how the semantic feedback
* tokens (`--success` / `--warning` / `--info`) land each tone above AA in
* both themes.
*/
--color-dose-accent: var(--dose-accent);
}
/*
* Dracula Theme
* Based on https://draculatheme.com/contribute
* Mapped to shadcn/ui CSS variable system
*/
/* ── Dracula palette (shared) ────────────────────── */
:root {
--dracula-bg: #282a36;
--dracula-current: #44475a;
--dracula-fg: #f8f8f2;
--dracula-comment: #9aa3b3;
--dracula-cyan: #8be9fd;
--dracula-green: #50fa7b;
--dracula-orange: #ffb86c;
--dracula-pink: #ff79c6;
--dracula-purple: #bd93f9;
--dracula-red: #ff5555;
--dracula-yellow: #f1fa8c;
--dracula-lavender: #d6acff;
/*
* v1.28.17 — the sleep-stage IN_BED fill. Distinct from `--dracula-comment`
* above (an orphaned token — no `@theme` utility was ever generated for
* it, per the `confidence-badge.ts` note — and its `#9aa3b3` value never
* matched the classic Dracula "Comment" blue-grey this chart actually
* paints). Minted here instead of retuning the unrelated token, so this
* default carries the exact hex the stacked bar hardcoded before
* (`#6272a4`) and the light override below (`:root.light`) is this
* token's own AA-tuned counterpart.
*/
--chart-inbed: #6272a4;
/*
* Semantic feedback tokens (see @theme `--color-success` / `--color-warning`
* / `--color-info` re-exports). Aliased over the Dracula palette so existing
* dark-mode visuals stay identical; light mode (Alucard) below provides the
* higher-contrast counterpart so AA contrast lands on the white card.
*/
--success: var(--dracula-green);
--warning: var(--dracula-orange);
--info: var(--dracula-cyan);
/*
* Brand accent (see the @theme re-export). Aliased over the pink
* primitive so the light theme's AA-tuned `--dracula-pink` override
* carries through automatically — pixel-identical to the raw utility
* sites it replaced.
*/
--brand-pink: var(--dracula-pink);
/*
* Foreground for glyphs/labels painted on a solid `bg-success` fill. The
* raw Dracula green (`#50fa7b`) is a bright tone, so white-on-green reads
* ~1.37:1 in dark mode — well under AA. The dark theme below pairs it with
* the near-black Dracula background (`#282a36`, ~10.4:1); the light theme
* keeps white on the darker light-mode green (`#14720a`, ~6.1:1). Mirrors
* the `--primary-foreground` pattern. Default here keeps the dark-theme
* value so an un-themed root never falls back to an unreadable pairing.
*/
--success-foreground: #282a36;
--radius: 0.625rem;
}
/* ── Dark mode (default) ─────────────────────────── */
:root.dark {
--background: #282a36;
--foreground: #f8f8f2;
--card: #1e1f29;
--card-foreground: #f8f8f2;
--popover: #1e1f29;
--popover-foreground: #f8f8f2;
--primary: #bd93f9;
--primary-foreground: #282a36;
--secondary: #44475a;
--secondary-foreground: #f8f8f2;
--muted: #44475a;
--muted-foreground: #9aa3b3;
/* Inactive tab-trigger text. `--muted-foreground` (#9aa3b3) on the
* muted (#44475a) tab-strip reads 3.6:1 — below WCAG AA. This brighter
* tone clears 4.5:1 on the muted strip while staying within the Dracula
* palette; the active trigger keeps `--foreground`. */
--tab-inactive: #b3bccd;
/* NativeSelect chevron — a data-URI background-image cannot resolve
* `currentColor` (it falls back to black per the SVG spec), so the
* muted-foreground value is baked into the stroke per theme. Keep in
* sync with `--muted-foreground` above. */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239aa3b3' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #44475a;
--accent-foreground: #f8f8f2;
--destructive: #ff5555;
--border: #44475a;
--input: #44475a;
/* An off switch's track, and a disabled primary button. Dark keeps the
* look these had (input at 80 %, primary at half strength). */
--switch-off: color-mix(in oklab, var(--input) 80%, transparent);
--primary-disabled: color-mix(
in oklab,
var(--primary) 50%,
var(--background)
);
--primary-disabled-foreground: color-mix(
in oklab,
var(--primary-foreground) 70%,
var(--primary-disabled)
);
/* Text-field focus border: a neutral, foreground-based tone, no colour
* ring. ~5.2:1 on --background, ~6:1 on --card. */
--input-focus: #9a9b9d;
--ring: #c4c9d4; /* neutral: no purple focus frames anywhere */
--chart-1: #bd93f9;
--chart-2: #50fa7b;
--chart-3: #ff79c6;
--chart-4: #8be9fd;
--chart-5: #ffb86c;
--sidebar: #1e1f29;
--sidebar-foreground: #f8f8f2;
--sidebar-primary: #bd93f9;
--sidebar-primary-foreground: #f8f8f2;
--sidebar-accent: #44475a;
--sidebar-accent-foreground: #f8f8f2;
--sidebar-border: #44475a;
--sidebar-ring: #bd93f9;
/*
* Status-pill text in dark mode. "in" / "near" keep the Dracula
* green / orange (both clear AA on the muted panel); "out" lifts off
* the raw `#ff5555`, which lands at 3.99:1 on `bg-muted/30`, to a
* lighter Dracula red that clears 4.5:1 on both card and panel.
*/
--status-pill-in: #50fa7b;
--status-pill-near: #ffb86c;
--status-pill-out: #ff8a8a;
/* Dark glyph on the bright Dracula-green fill — ~10.4:1 on `#50fa7b`. */
--success-foreground: #282a36;
/*
* Dose accent in dark mode — the exact Tailwind `text-purple-400`
* (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card
* stays pixel-identical. AA on the dark card at ~6.8:1.
*/
--dose-accent: oklch(0.714 0.203 305.504);
/*
* v1.18.7 — wellness-ring per-metric arc colours (Dracula dark), CALM pass.
* Five DISTINCT but MUTED single-hue arcs: readiness green · recovery cyan ·
* sleep purple · stress orange · strain pink. The earlier set ran a
* dark→neon two-stop sweep (`to` == the raw Dracula palette colour) plus a
* bloom + sheen, which read as glowing/3-D against the flat Recharts charts.
* The arc is now a near-flat single tone per hue: `from`/`to` sit a hair
* apart for a whisper of depth, both pulled down from the neon palette and
* desaturated toward the chart colours, so the ring reads as quiet as a
* chart series. The Alucard light set lives on `:root.light`.
*/
--ring-readiness-from: #3fb86b;
--ring-readiness-to: #46c476;
--tile-readiness: #50fa7b;
--ring-recovery-from: #4fb6cc;
--ring-recovery-to: #58c0d6;
--tile-recovery: #8be9fd;
--ring-sleep-from: #9a73da;
--ring-sleep-to: #a37fe0;
--tile-sleep: #bd93f9;
--ring-stress-from: #e09a52;
--ring-stress-to: #e8a55e;
--tile-stress: #ffb86c;
--ring-strain-from: #e06aa8;
--ring-strain-to: #ea74b1;
--tile-strain: #ff79c6;
/* Band fallback (anatomy detail view, no per-metric hue). Lands on the
app-wide --success green (#50fa7b) so a "green" ring matches the green
of every positive signal / trend delta beside it. */
--ring-green-from: #35d76a;
--ring-green-to: #50fa7b;
--ring-yellow-from: #e0a050;
--ring-yellow-to: #e8ab5c;
--ring-red-from: #e0594a;
--ring-red-to: #e96456;
/*
* v1.15.0 — cycle-phase ring hues (Dracula dark). Four calm, distinct
* tones for the cycle wheel: menstrual rose · follicular green · ovulatory
* amber-gold · luteal violet. Tuned to read as a soft instrument, never a
* gaudy rainbow — the same restraint as the wellness rings above.
*/
--cycle-phase-menstrual: #ff6e9c;
--cycle-phase-follicular: #5cf08f;
--cycle-phase-ovulatory: #ffce6b;
--cycle-phase-luteal: #bd93f9;
color-scheme: dark;
}
/* ── Light mode (Alucard — official Dracula light) ── */
:root.light {
/*
* Neutral spine (retuned v1.26.0). Softened off the pure-white / pure-black
* high-contrast set — a faint mauve tint (hue ~295–300) runs through every
* neutral so the light theme reads gentle rather than harsh. Foreground is a
* tinted near-black (not #000), dropping fg-on-card from ~16.5:1 to a
* comfortable ~13:1. Accent + semantic tokens below stay as the Alucard
* AA-tuned set. oklch throughout so lightness steps are perceptually even.
*/
--background: oklch(0.964 0.004 300);
--foreground: oklch(0.3 0.012 295);
--card: oklch(0.988 0.003 300);
--card-foreground: oklch(0.3 0.012 295);
--popover: oklch(0.992 0.002 300);
--popover-foreground: oklch(0.3 0.012 295);
--primary: #644ac9;
--primary-foreground: oklch(0.99 0.002 300);
--secondary: oklch(0.95 0.008 300);
--secondary-foreground: oklch(0.3 0.012 295);
--muted: oklch(0.955 0.006 300);
--muted-foreground: oklch(0.515 0.02 295);
/* Inactive tab-trigger text — the light muted-foreground clears AA on the
* light muted strip, so the token mirrors it. */
--tab-inactive: oklch(0.515 0.02 295);
/* Light-theme counterpart of the NativeSelect chevron (see dark block).
* Stroke tracks the muted-foreground hex (#686572). */
--native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23686572' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: oklch(0.95 0.008 300);
--accent-foreground: oklch(0.3 0.012 295);
--destructive: #cb3a2a;
/*
* Alucard light-mode overrides for the semantic tokens — straight Dracula
* green/orange/cyan would fail AA contrast on a white card. These match the
* darker palette already used for `--destructive`/`--chart-*` in light mode.
*/
--success: #14720a;
--warning: #a34d14;
--info: #036a96;
/* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */
--success-foreground: #ffffff;
--border: oklch(0.885 0.008 300);
--input: oklch(0.87 0.009 300);
/* An off switch's track: the input grey sat at ~1.4:1 on the card, so an
* off switch all but vanished. ~3.5:1 on --card clears the 3:1 floor for
* a control boundary. */
--switch-off: oklch(0.62 0.012 295);
/* A disabled primary button: opacity-50 left white text on a washed
* violet at ~2:1. A pale violet fill with a dark violet label (~5.5:1)
* still reads as the primary action, and as unavailable. */
--primary-disabled: oklch(0.88 0.035 290);
--primary-disabled-foreground: oklch(0.44 0.06 290);
/* Text-field focus border, neutral: ~4.2:1 on --background. */
--input-focus: oklch(0.56 0.012 295);
--ring: oklch(0.48 0.012 290); /* neutral: no purple focus frames anywhere */
--chart-1: #644ac9;
--chart-2: #14720a;
--chart-3: #a3134d;
--chart-4: #036a96;
--chart-5: #a34d14;
/*
* Light-mode (Alucard) overrides for the raw `--dracula-*` primitives.
* The semantic sweep retired every `text-dracula-*` / `bg-dracula-*`
* UTILITY site (lint-enforced via healthlog/no-dracula-utility), but
* chart code still passes `var(--dracula-*)` token references
* (Recharts stroke/fill maps) and the Coach gradient CSS below reads
* the purple/pink primitives — without an override the dark neons
* render on the white card at 1.2–1.6:1. Each value below is tuned to
* the AA light palette already in this block (same hue family,
* measured >=4.5:1 on both `--card` oklch(0.988 0.003 300) and
* `--background` oklch(0.964 0.004 300)).
*/
--dracula-cyan: #036a96; /* shadows --info — 5.78:1 card / 5.39:1 bg */
--dracula-green: #14720a; /* shadows --success — 5.89:1 card / 5.49:1 bg */
--dracula-orange: #a34d14; /* shadows --warning — 5.59:1 card / 5.21:1 bg */
--dracula-pink: #a3134d; /* shadows --chart-3 — 7.35:1 card / 6.85:1 bg */
--dracula-purple: #644ac9; /* shadows --primary/chart-1 — 6.03:1 card / 5.62:1 bg */
--dracula-red: #cb3a2a; /* shadows --destructive — 4.85:1 card / 4.52:1 bg */
--dracula-yellow: #846e15;
/* Lavender has no semantic twin; a violet one step lighter than purple,
* AA-tuned — 4.97:1 card / 4.63:1 bg. */
--dracula-lavender: #7c50e0;
/* IN_BED sleep-stage fill (see the `:root` default above) — same
* blue-grey hue family, darkened for AA — 6.66:1 card / 6.03:1 bg. */
--chart-inbed: #4a5b8a;
--sidebar: oklch(0.988 0.003 300);
--sidebar-foreground: oklch(0.3 0.012 295);
--sidebar-primary: #644ac9;
--sidebar-primary-foreground: oklch(0.99 0.002 300);
--sidebar-accent: oklch(0.95 0.008 300);
--sidebar-accent-foreground: oklch(0.3 0.012 295);
--sidebar-border: oklch(0.885 0.008 300);
--sidebar-ring: oklch(0.6 0.13 292);
/*
* v1.18.7 — wellness-ring per-metric arc colours (Alucard light), CALM pass.
* Deep + saturated enough to hold AA presence on the white `--card` (raw
* Dracula cyan/green wash out on white), but the dark→light two-stop SWEEP
* is collapsed to a near-flat single tone (`from`/`to` a hair apart) so the
* arc reads as quiet as a chart series rather than a glowing gradient. Same
* five hue families as the dark set: readiness green · recovery cyan · sleep
* purple · stress orange · strain pink.
*/
--ring-readiness-from: #138043;
--ring-readiness-to: #168a48;
--tile-readiness: #14720a;
--ring-recovery-from: #07849c;
--ring-recovery-to: #098ba3;
--tile-recovery: #08869e;
--ring-sleep-from: #6638c2;
--ring-sleep-to: #6c3fc8;
--tile-sleep: #6233c0;
--ring-stress-from: #b27018;
--ring-stress-to: #b8771c;
--tile-stress: #a34d14;
--ring-strain-from: #b62478;
--ring-strain-to: #bd2a7e;
--tile-strain: #b0277d;
/* Light: lands on the light-theme --success (#14720a) so ring green
matches every positive signal beside it. */
--ring-green-from: #12760f;
--ring-green-to: #14720a;
--ring-yellow-from: #b27018;
--ring-yellow-to: #bd7a1e;
--ring-red-from: #b22e20;
--ring-red-to: #ba3526;
/*
* v1.15.0 — cycle-phase ring hues (Alucard light). Deeper + more
* saturated than the dark set so each phase holds AA-grade presence on the
* white card. Same four distinct hues: menstrual rose · follicular green ·
* ovulatory amber · luteal violet.
*/
--cycle-phase-menstrual: #c8295e;
--cycle-phase-follicular: #0f7a3a;
--cycle-phase-ovulatory: #a34d14;
--cycle-phase-luteal: #644ac9;
/*
* Status-pill text in light mode (Alucard). The bright Dracula
* green / orange / red read 1.2–2.7:1 on a near-white tint; these
* darker tones — the same family already used for `--success` /
* `--warning` / the light-mode reds — clear AA on both the white card
* and the muted reference panel.
*/
--status-pill-in: #14720a;
--status-pill-near: #a34d14;
--status-pill-out: #b32d1e;
/*
* Dose accent in light mode (Alucard). The bright `text-purple-400` reads
* ~3.1:1 on the white card and fails AA; this darker purple — the same
* family as the light-mode `--primary` (#644ac9) — clears 4.5:1 on both
* the white card and the muted panel while keeping the purple identity.
*/
--dose-accent: #6f42c1;
color-scheme: light;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
/*
* Reserve the scrollbar gutter on the document root ONLY for the
* body-scrolled shells (login / register / onboarding / standalone legal
* pages), where the document itself overflows. Those keep a fixed content
* width whether or not the page paints the bar, so any `mx-auto` content
* does not recentre a few px sideways on navigation. `AuthShell` sets
* `data-scroll="body"` on `<html>` for exactly those branches.
*
* The authenticated app shell is height-locked (`flex h-dvh`) and scrolls
* inside its own `<main>`, which reserves its own gutter. Applying this
* rule globally there reserved a SECOND, never-painted gutter on classic
* (non-overlay) scrollbar platforms (Windows / Linux) — a ~15px dead band.
* Scoping it to the body-scrolled branch leaves exactly one gutter per
* branch.
*/
html[data-scroll="body"] {
scrollbar-gutter: stable;
}
body {
@apply bg-background text-foreground;
}
p {
color: var(--foreground);
}
}
/*
* The shell's safe area, the on-screen keyboard and the sheet clearances sit
* in the components layer, under the utilities, so a utility on the same
* element still wins and these rules cannot silently override one. The
* bottom sheet takes its `bottom` from here alone for that reason (the
* sheet primitive sets no `bottom-*`); the keyboard rules that must beat a
* utility say `!important`, which in a layer outranks any utility.
*/
@layer components {
/*
* `.shell-safe-area` — the authenticated app shell's share of the installed
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
* and side insets as padding, and the strip behind the status bar is painted
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
* the page colour, so the status bar reads as part of the bar beneath it. A
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
*/
.shell-safe-area {
padding-top: env(safe-area-inset-top, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-left: env(safe-area-inset-left, 0px);
background: linear-gradient(var(--card), var(--card)) top / 100%
env(safe-area-inset-top, 0px) no-repeat;
}
/*
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
* shrink only the visual viewport when the keyboard opens: everything
* `position: fixed` stays anchored to the layout viewport, so a bottom sheet
* keeps its footer (Save, the Coach composer) behind the keyboard and the
* bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once
* in the providers) publishes the covered height as `--keyboard-inset` and
* sets `data-keyboard` on `<html>` while it is open.
*
* Bottom sheets then stand on the keyboard and give up the height it takes,
* so their own scroll body (every bottom sheet is a flex column with a
* scrolling middle) carries the form, and the bottom bar and the Coach
* button step aside until the keyboard is gone.
*/
[data-sheet-side="bottom"] {
bottom: var(--keyboard-inset, 0px);
}
html[data-keyboard="open"] [data-sheet-side="bottom"] {
max-height: calc(
100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) -
0.5rem
) !important;
}
html[data-keyboard="open"] [data-keyboard-hide] {
display: none !important;
}
/*
* Side and top sheets run the full height of the window. In an installed
* app on a tablet the status bar sits over their first 24 px, and a phone
* held sideways has its notch over one side, so the panel keeps its header
* clear of both. A transparent border rather than padding: every consumer
* sets its own padding (often `p-0`), which would override a padding here,
* while the border adds the clearance under any of them and lets the
* panel's own background show through it.
*/
[data-sheet-side="right"],
[data-sheet-side="left"],
[data-sheet-side="top"] {
border-top: env(safe-area-inset-top, 0px) solid transparent;
}
[data-sheet-side="right"] {
border-right: env(safe-area-inset-right, 0px) solid transparent;
}
[data-sheet-side="left"] {
border-left: env(safe-area-inset-left, 0px) solid transparent;
}
}
/*
* Toasts clear the bottom bar wherever the phone shell shows it. The
* toaster's `mobileOffset` (providers.tsx) applies below 600 px only, while
* the bar follows the shell (up to `md`, and a phone held sideways): between
* the two the "new version" toast sat over the tabs. Unlayered and one `html`
* deeper than Sonner's injected `[data-sonner-toaster][data-y-position]`
* rule, which is unlayered too and lands after this sheet. The Coach panel's
* rule further down still outranks it while that panel is open.
*/
html [data-sonner-toaster][data-y-position="bottom"] {
@variant shell-mobile {
bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 0.75rem);
}
}
/*
* `.no-scrollbar` — keeps a container scrollable but hides the visible
* bar. Used by the mobile section strips in `<AdminShell>` and
* `<SettingsShell>` so 13 admin / 10 settings entries can swipe
* horizontally without painting an always-on scrollbar at the top of
* every admin/settings page (a recurring maintainer complaint — most
* recently mistaken for a `/admin/api-tokens` table scrollbar in
* v1.4.16/17 because the strip sits right above the api-tokens card).
*
* Standard `scrollbar-width: none` (Firefox) + `::-webkit-scrollbar`
* (Chromium/WebKit) covers every browser HealthLog targets. Touch
* scrolling, keyboard arrow scrolling, and `Tab` focus traversal all
* keep working — this is a purely cosmetic suppression, not an
* `overflow: hidden`.
*/
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/*
* `.scroll-touch` — momentum scrolling for horizontal scrollers on iOS
* WebKit. `-webkit-overflow-scrolling: touch` restores the inertial
* flick the default `overflow-x-auto` row loses on touch; `overscroll-
* behavior: contain` keeps a flick that reaches the end of the row from
* chaining into the document scroll (the "rubber-band drags the whole
* page" paper-cut). Applied to the in-app horizontal pill / tab strips
* (insights tab strip, cycle tab list) so the gesture matches the
* native feel the bottom-sheet scrollers already get for free.
*/
.scroll-touch {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ── Insight card animations ─────────────────────── */
@keyframes insight-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-insight-in {
animation: insight-fade-in 0.4s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-insight-in {
animation: none;
}
}
/* ── v1.4.20 phase B1 — Insights redesign hero strip ─────────────── */
/*
* `.hero-gradient` — purple → pink linear gradient at low opacity, for
* the new <HeroStrip> band on /insights. Sits above `var(--card)` so
* the band reads as a richer surface than the card it overlays.
*
* Matches the design handoff (`prototype/artboard-fullpage.jsx →
* BriefingHero` background) — purple/pink at ~12% opacity over the
* card colour, with a subtle border-tint to anchor it on the dark bg.
*/
.hero-gradient {
background: linear-gradient(
135deg,
color-mix(in srgb, var(--dracula-purple) 18%, var(--card)) 0%,
color-mix(in srgb, var(--dracula-pink) 12%, var(--card)) 100%
);
border: 1px solid color-mix(in srgb, var(--dracula-purple) 24%, transparent);
}
/*
* `.glow-purple` — soft purple aura behind the hero card, painted via
* `box-shadow` so it stays GPU-cheap and respects the existing card
* border radius without an extra wrapper element.
*/
.glow-purple {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--dracula-purple) 16%, transparent),
0 18px 48px -28px color-mix(in srgb, var(--dracula-purple) 50%, transparent);
}
/*
* `.wellness-tile` — the v1.13.x premium-redesign wellness-tile surface.
*
* Replaces the old saturated `.score-tile-gradient` dark slab. Mirrors the
* gentle, calm `.hero-gradient` the maintainer loves: a LOW-opacity mix of
* the metric's `--tile-hue` over the *theme* `var(--card)` (not the fixed
* dark bg), so the surface stays luminous in both Dracula dark + Alucard
* light. The mix is even softer than the hero's 18/12 (14/7) so a *row* of
* five tinted tiles never overwhelms the page. Each `RingTile` sets
* `--tile-hue` inline per metric; the per-metric `hue` arc on the ring is
* the only saturated element, letting the dial be the hero.
*
* Text rides the theme `--foreground` (dark in light mode, light in dark
* mode), so AA is trivial over the `--card`-based surface in both themes —
* the old white-pinned copy + white arc are gone.
*/
.wellness-tile {
--tile-hue: var(--dracula-purple);
position: relative;
overflow: hidden;
background: linear-gradient(
140deg,
color-mix(in srgb, var(--tile-hue) 14%, var(--card)) 0%,
color-mix(in srgb, var(--tile-hue) 7%, var(--card)) 100%
);
border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent);
box-shadow: 0 10px 30px -26px
color-mix(in srgb, var(--tile-hue) 40%, transparent);
transition:
transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
border-color 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
}
/*
* Faint static film-grain over the tile — lifts the flat CSS gradient to a
* "material" surface (the Oura touch). Inline SVG fractalNoise data-URI, zero
* network cost, one paint, no animation (reduced-motion-safe by construction).
* Opacity kept <=3% so it never reads as dirty on the white Alucard card.
*/
.wellness-tile::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.025;
mix-blend-mode: overlay;
pointer-events: none;
}
/*
* v1.15.12 (F1/F3) — `.wellness-detail-card` is the score-anatomy detail
* surface, deepened to match the dashboard ring tile the user tapped.
*
* F1 — carries the SAME per-metric `--tile-hue` mix over the theme `--card`
* as `.wellness-tile`, so the tap-through reads as the same surface
* rather than a plain bordered card. The hue is set inline per metric.
* F3 — a SUBTLE vertical gradient that lightens toward the BOTTOM: the top
* carries a touch more of the hue (16%) and it eases to 6% at the foot,
* giving the card a premium, lit-from-below depth. Theme-aware by
* construction — both stops mix over the theme `--card`, so it stays
* luminous on the white Alucard card and rich on the dark Dracula one.
* Kept minimal (a 10pp swing) so it never reads as a heavy slab.
*
* Static, no hover lift (it is a page surface, not a tap target) — so it
* needs no reduced-motion handling.
*/
.wellness-detail-card {
--tile-hue: var(--dracula-purple);
position: relative;
/* The bottom-leaning gradient below is the card's OWN `background`, which is
* already clipped to `border-radius` — a painted background never bleeds past
* the rounded corners regardless of `overflow`. We deliberately do NOT set
* `overflow: hidden`: the ring's drop-shadow bloom (`.wellness-ring-arc`) is a
* CHILD box, and `overflow: hidden` would clip that glow at the card edge. The
* gradient stays corner-clipped; the glow renders uncut on all four sides. */
background: linear-gradient(
180deg,
color-mix(in srgb, var(--tile-hue) 16%, var(--card)) 0%,
color-mix(in srgb, var(--tile-hue) 6%, var(--card)) 100%
);
border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent);
box-shadow: 0 12px 34px -28px
color-mix(in srgb, var(--tile-hue) 42%, transparent);
}
@media (hover: hover) {
.wellness-tile:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent);
box-shadow: 0 16px 40px -24px
color-mix(in srgb, var(--tile-hue) 52%, transparent);
}
.wellness-tile:hover [data-slot="score-ring"] {
transform: scale(1.015);
}
}
.wellness-tile:active {
transform: translateY(0) scale(0.985);
}
/* So the ring scales smoothly with the tile lift instead of snapping. */
.wellness-tile [data-slot="score-ring"] {
transition: transform 200ms var(--ring-ease);
}
/*
* v1.18.7 — the wellness-strip reveal, calmed. What remains is a quiet
* left-to-right tile rise+fade (gated once per session via `data-revealed`)
* and the arc's spring-fill sweep (in score-ring.tsx, `--ring-spring`). The
* conic specular sheen + the bloom + the breathing pulse were removed so the
* strip settles as flat as the surrounding charts. A single
* prefers-reduced-motion block at the bottom zeroes the tile rise.
*/
:root {
/* Sampled spring (stiffness ~120, damping ~14): overshoots ~6% then settles
— the Oura/WHOOP fill feel. Falls back to the browser default on very old
engines that ignore linear() (instant fill, not broken). */
--ring-spring: linear(
0,
0.06,
0.23,
0.46,
0.7,
0.89,
1.02,
1.06,
1.03,
1,
0.99,
1
);
--ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
.wellness-tile-rise {
opacity: 0;
transform: translateY(10px);
}
[data-revealed="true"] .wellness-tile-rise {
animation: wellnessTileRise 440ms var(--ring-ease) forwards;
animation-delay: var(--reveal-delay, 0ms);
}
@keyframes wellnessTileRise {
to {
opacity: 1;
transform: translateY(0);
}
}
/* v1.18.7 — the conic specular SHEEN is retired. It swept a white highlight
across the arc once on reveal, which was the most overtly "premium / 3-D"
touch and clashed hardest with the flat charts. The class is kept as a
no-op so the cycle-ring + score-ring call sites that still render the
overlay div simply paint nothing. */
.wellness-ring-sheen {
display: none;
}
/* v1.18.7 — the arc is FLAT by default. The earlier treatment painted a
token-tinted drop-shadow bloom behind every arc plus a breathing pulse on
the green-band ring; against the flat 2-D Recharts charts that read as a
glowing 3-D dial. Both are gone — the arc is a plain stroke, the same
weight a chart series carries. `.wellness-ring-arc--flat` is kept as an
alias so the dashboard-hero call site (and any other) stays valid. */
.wellness-ring-arc,
.wellness-ring-arc--flat,
.wellness-ring-arc--pulse {
filter: none;
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.wellness-tile,
.wellness-tile:hover,
.wellness-tile:active,
.wellness-tile [data-slot="score-ring"],
.wellness-tile:hover [data-slot="score-ring"] {
transform: none;
transition: none;
}
.wellness-tile-rise,
[data-revealed="true"] .wellness-tile-rise {
opacity: 1;
transform: none;
animation: none;
}
}
/*
* `pulseDot` keyframes — the "live, refreshed N min ago" green dot in
* the hero meta row. Used as `.pulse-dot { animation: pulseDot ... }`.
* Reduced-motion users get a static dot via the prefers-reduced-motion
* guard below.
*/
@keyframes pulseDot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.85);
}
}
.pulse-dot {
animation: pulseDot 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.pulse-dot {
animation: none;
}
}
/*
* v1.15.0 — the cycle-ring "signature reveal".
*
* The phase dial earns the same premium signature as the wellness rings
* (sweep + glow + sheen + count-up) without losing its own identity as a
* four-phase dial:
* 1. a clockwise CLOCK-WIPE draws the phase arcs on from the 12-o'clock
* day-0 origin (the <svg> already carries `-rotate-90`, so the conic
* origin lines up with both the arcs and the current-day marker);
* 2. a phase-tinted bloom on the active arc (shared `.wellness-ring-arc`,
* `--ring-glow` set inline to the active phase hue);
* 3. the shared `.wellness-ring-sheen` specular pass + `.wellness-tile`
* surface the strip already uses;
* 4. a soft halo pulse on the current-day marker so "today" reads as alive.
* All gated on the host's `data-revealed` (set once per session) so a
* background calendar refetch never re-triggers the moment, and all zeroed
* under prefers-reduced-motion.
*/
@property --cycle-wipe {
syntax: "<angle>";
inherits: false;
initial-value: 360deg;
}
.cycle-ring-reveal {
-webkit-mask: conic-gradient(#000 var(--cycle-wipe), transparent 0);
mask: conic-gradient(#000 var(--cycle-wipe), transparent 0);
}
[data-revealed="true"] .cycle-ring-reveal {
animation: cycleRingWipe 1100ms var(--ring-ease) forwards;
}
@keyframes cycleRingWipe {
from {
--cycle-wipe: 0deg;
}
to {
--cycle-wipe: 360deg;
}
}
/* The current-day marker's halo — a few gentle pulses on reveal, then calm
(a finite count, never an infinite repaint of the non-compositable ring). */
.cycle-ring-marker-halo {
transform-box: fill-box;
transform-origin: center;
opacity: 0;
}
[data-revealed="true"] .cycle-ring-marker-halo {
animation: cycleMarkerHalo 2.4s ease-in-out 950ms 3;
}
@keyframes cycleMarkerHalo {
0%,
100% {
opacity: 0;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(1.9);
}
}
@media (prefers-reduced-motion: reduce) {
.cycle-ring-reveal,
[data-revealed="true"] .cycle-ring-reveal {
-webkit-mask: none;
mask: none;
animation: none;
}
.cycle-ring-marker-halo,
[data-revealed="true"] .cycle-ring-marker-halo {
animation: none;
opacity: 0;
}
}
/*
* v1.15.1 — the cycle-length history chart reveal.
*
* The hand-rolled SVG bars grow up from the baseline on first paint, a calm
* staggered rise (per-bar `--bar-delay`) that matches the wheel's 0-KB SVG
* identity. Gated on the chart's own `data-animate="true"` (the view only sets
* it when motion is allowed) so a background history refetch never replays it,
* and zeroed entirely under prefers-reduced-motion (bars paint at full height).
*/
.cycle-history-bar {
transform-box: fill-box;
transform-origin: bottom;
}
[data-animate="true"] .cycle-history-bar {
transform: scaleY(0);
animation: cycleHistoryBarRise 520ms var(--ring-ease, ease-out) forwards;
animation-delay: var(--bar-delay, 0ms);
}
@keyframes cycleHistoryBarRise {
to {
transform: scaleY(1);
}
}
@media (prefers-reduced-motion: reduce) {
[data-animate="true"] .cycle-history-bar {
transform: none;
animation: none;
}
}
/*
* ── v1.29.0 cohesion pass — the daily-value system's finishing layer ──
*
* Four small surface treatments, all expressed through the existing
* token/motion vocabulary (no new palette, no new easing):
*
* `.today-hero-wash` — the ONE sanctioned Today atmosphere: a faint
* `--primary` mix over the theme `--card` in the
* `.wellness-tile` pattern, leaning toward the
* ring corner. Softer than the insights
* `.hero-gradient` (8% → 0 vs 18/12) so the
* promoted hero reads as the calm first surface,
* not a banner. Static — nothing to motion-gate.
* `.metric-accent` — per-metric identity edge for rail cards and
* cross-link pointers. Reads `--tile-hue` (set
* inline from the SAME `--tile-*` vocabulary the
* wellness tiles use; `--primary` for the med /
* coach family) and paints a soft inset edge on
* the reading side via a ::before overlay, so it
* composes with the card's own `shadow-sm` and
* the semantic status washes (which keep the
* background). No layout width is added; the
* inset shadow follows the border radius.
* `.milestone-reached` — the S12 quiet celebration: a soft `--success`
* halo over the theme `--card` plus a one-shot
* spring reveal on the shared `--ring-spring`
* curve. No confetti, no loop; reduced-motion
* paints the settled card at once.
* `.skeleton-shimmer` — the loading language upgrade: a single soft
* `--foreground`-mix sweep across the muted
* skeleton base instead of the whole-block
* `animate-pulse` throb. Load-terminating by
* construction (skeletons unmount with the data);
* reduced-motion collapses to the static base.
*/
.today-hero-wash {
background: radial-gradient(
120% 150% at 100% 0%,
color-mix(in srgb, var(--primary) 8%, var(--card)) 0%,
var(--card) 60%
);
}
.metric-accent {
position: relative;
}
.metric-accent::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 3px 0 0 0
color-mix(in srgb, var(--tile-hue, var(--primary)) 55%, transparent);
}
.milestone-reached {
background: radial-gradient(
130% 130% at 85% 0%,
color-mix(in srgb, var(--success) 14%, var(--card)) 0%,
color-mix(in srgb, var(--success) 4%, var(--card)) 60%,
var(--card) 100%
);
animation: milestone-reached-in 640ms var(--ring-spring) both;
}
@keyframes milestone-reached-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.milestone-reached {
animation: none;
}
}
@keyframes skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.skeleton-shimmer {
background-image: linear-gradient(
100deg,
transparent 36%,
color-mix(in srgb, var(--foreground) 6%, transparent) 50%,
transparent 64%
);
background-size: 250% 100%;
background-repeat: no-repeat;
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background-image: none;
}
}
/* ── Live status text ────────────────────────────────────────────────
* `text-shimmer`: a band of foreground light sweeps through muted text, for a
* line that says something is still in progress (the Coach's thinking row).
* The gradient is clipped to the glyphs; the band travels left to right once
* every two seconds. Under reduced motion the text is plain muted text.
*
* `waiting-dot`: one of three trailing dots that fade in turn (the caller
* staggers them by 0.2 s). Under reduced motion they stand still at full
* opacity, which the global backstop below would not give them: it pins the
* last frame, and the last frame is dim. */
@keyframes text-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0% 0;
}
}
@utility text-shimmer {
background-image: linear-gradient(
90deg,
var(--muted-foreground) 40%,
var(--foreground) 50%,
var(--muted-foreground) 60%
);
background-size: 250% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: text-shimmer 2s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
background-image: none;
color: var(--muted-foreground);
}
}
@keyframes waiting-dot {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
@utility waiting-dot {
animation: waiting-dot 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
}
}
/* ── Global reduced-motion backstop ──────────────────────────────────
* Defensive catch-all so any animation/transition we forget to gate
* per-component still collapses to near-instant when the user has asked
* for reduced motion. The per-component blocks above stay — they carry
* tailored static fallbacks (static dots, full-height bars). This is the
* floor, not a replacement. Near-zero (not 0) keeps JS animation-end /
* transition-end listeners firing so state machines don't stall.
*
* `animation-fill-mode: forwards` + `animation-delay: 0ms` are load-bearing:
* several entrance animations rest at `opacity: 0` and only become visible
* through their reveal keyframe. Without holding the end state (and without
* zeroing the stagger delay) the near-zero animation completes and the
* element snaps back to its hidden base style — leaving charts and list
* rows permanently invisible under reduced motion. Forwards pins the final
* (revealed) frame; zero delay reveals immediately. Per-component blocks
* above that set `animation: none` are unaffected (no animation to fill). */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
animation-fill-mode: forwards !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ── Toasts clear the Coach's New chat button ────────────────────────
* The Coach's conversations panel keeps its round New chat button in the
* bottom-right corner, which is where every toast lands. While the panel is
* open (docked or as a sheet), bottom toasts rise by the button's footprint
* (48 px plus its 16 px inset) so a delete's Undo never covers it. The
* selectors outrank sonner's own `bottom` rules, phone width included. */
:root:has([data-slot="coach-conversations-panel"][data-state="open"])
[data-sonner-toaster][data-y-position="bottom"] {
bottom: calc(var(--offset-bottom) + 4rem);
}
@media (max-width: 600px) {
:root:has([data-slot="coach-conversations-panel"][data-state="open"])
[data-sonner-toaster][data-y-position="bottom"] {
bottom: calc(var(--mobile-offset-bottom) + 4rem);
}
}