mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
1305 lines
50 KiB
CSS
1305 lines
50 KiB
CSS
@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);
|
||
}
|
||
}
|