Files
HealthLog/src/app/globals.css
T
Marc-André Bombeck 247bc4f33c fix(ui): close consistency-audit contrast and duplication findings
Today hero's top-signal delta used opacity-on-muted text, landing below
AA on the app's most prominent card; five mood charts and the sleep-
stage IN_BED fill painted raw --dracula-*/hardcoded-hex hues with no
(or an unused) light-theme counterpart. Route all of them through
existing semantic/chart tokens instead (destructive/warning/success,
chart-1..5, and a newly minted --chart-inbed with its own Alucard
override) so every hue is AA-tuned per theme.

Also collapses three duplicated primitives the audit flagged: the
click-Popover VitalsInfoTip and hover-Tooltip InfoHint merge into one
touch-first ui/info-popover; MedicationViewToggle and ModuleViewToggle
were verbatim copy-paste and now wrap one shared ui/view-toggle; the
triplicated sport-icon map (already drifted — mixedCardio rendered a
different glyph in the list vs the detail/dashboard views) moves to
lib/workouts/sport-icons.ts with HeartPulse as the settled icon.

Adds a no-restricted-imports lint rule banning direct recharts imports
outside the chart-runtime barrel, catching the bundle-duplication
mistake at local lint instead of PR-CI.
2026-07-18 01:23:37 +02:00

1049 lines
40 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 *));
@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-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);
--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;
--ring: #bd93f9;
--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);
--ring: oklch(0.6 0.13 292);
--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);
}
}
/*
* `.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;
}
}
/* ── 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;
}
}