Files
HealthLog/src/app/globals.css
T
Marc-André Bombeck 547a7f8904 fix(a11y): give the due-reminder action a readable foreground
The Vorsorge "Jetzt messen" / mark-done button hard-coded white text on a
solid green fill. In the dark theme the fill is the bright Dracula green, so
white-on-green reads about 1.37:1 — below the WCAG AA floor.

Add a theme-aware `--success-foreground` token (near-black glyph on the dark
fill, white on the darker light-mode green) mirroring the existing
`--primary-foreground` pattern, and use `text-success-foreground` on both the
card and list variants. Green stays only on the action button; the card
surface remains neutral.
2026-06-18 22:41:31 +02:00

938 lines
33 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 {
--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);
/*
* 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;
/*
* 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);
/*
* 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.14.0 — wellness-ring per-metric gradients (Dracula dark).
* Five DISTINCT single-hue two-stop arcs (from = darker, to = lighter):
* readiness green · recovery cyan · sleep purple · stress orange ·
* strain pink. Every `to` stop IS the matching Dracula palette colour
* and the `from` stop is a hand-darkened tone of the same hue, so the
* arcs read as Dracula rather than generic chart colours (the v1.14.0
* set leaned indigo/teal/violet, which sat next to the palette instead
* of on it). Each `to` doubles as the ring's bloom colour. The Alucard
* light set lives on `:root.light`.
*/
--ring-readiness-from: #1fa854;
--ring-readiness-to: #50fa7b;
--tile-readiness: #50fa7b;
--ring-recovery-from: #2fa8c4;
--ring-recovery-to: #8be9fd;
--tile-recovery: #8be9fd;
--ring-sleep-from: #8655d6;
--ring-sleep-to: #bd93f9;
--tile-sleep: #bd93f9;
--ring-stress-from: #d9842e;
--ring-stress-to: #ffb86c;
--tile-stress: #ffb86c;
--ring-strain-from: #d6429b;
--ring-strain-to: #ff79c6;
--tile-strain: #ff79c6;
/* Band fallback (anatomy detail view, no per-metric hue). */
--ring-green-from: #1f9d57;
--ring-green-to: #5cf08f;
--ring-yellow-from: #e0922f;
--ring-yellow-to: #ffce6b;
--ring-red-from: #c93a2a;
--ring-red-to: #ff7a6b;
/*
* 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 {
--background: #f5f5f5;
--foreground: #1f1f1f;
--card: #ffffff;
--card-foreground: #1f1f1f;
--popover: #ffffff;
--popover-foreground: #1f1f1f;
--primary: #644ac9;
--primary-foreground: #ffffff;
--secondary: #dcdeef;
--secondary-foreground: #1f1f1f;
--muted: #dcdeef;
--muted-foreground: #5b6273;
/* Inactive tab-trigger text — the light muted-foreground already clears
* AA (4.58:1) on the light muted strip, so the token mirrors it. */
--tab-inactive: #5b6273;
/* Light-theme counterpart of the NativeSelect chevron (see dark block). */
--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='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
--accent: #dcdeef;
--accent-foreground: #1f1f1f;
--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: #cfcfde;
--input: #cfcfde;
--ring: #644ac9;
--chart-1: #644ac9;
--chart-2: #14720a;
--chart-3: #a3134d;
--chart-4: #036a96;
--chart-5: #a34d14;
--sidebar: #ffffff;
--sidebar-foreground: #1f1f1f;
--sidebar-primary: #644ac9;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #dcdeef;
--sidebar-accent-foreground: #1f1f1f;
--sidebar-border: #cfcfde;
--sidebar-ring: #644ac9;
/*
* v1.14.0 — wellness-ring per-metric gradients (Alucard light). DEEPER +
* more saturated than the dark set so each hue holds AA-grade presence on
* the white `--card` (raw Dracula cyan/green wash out on white). Same five
* Dracula hue families as the dark set: readiness green · recovery cyan ·
* sleep purple · stress orange · strain pink.
*/
--ring-readiness-from: #0f7a3a;
--ring-readiness-to: #18a34e;
--tile-readiness: #14720a;
--ring-recovery-from: #067f96;
--ring-recovery-to: #0d95b0;
--tile-recovery: #08869e;
--ring-sleep-from: #5b2db8;
--ring-sleep-to: #7a45d6;
--tile-sleep: #6233c0;
--ring-stress-from: #b06a16;
--ring-stress-to: #c47a1e;
--tile-stress: #a34d14;
--ring-strain-from: #ad1e6f;
--ring-strain-to: #cc2e88;
--tile-strain: #b0277d;
--ring-green-from: #0f7a3a;
--ring-green-to: #18a34e;
--ring-yellow-from: #b06a16;
--ring-yellow-to: #d68a2a;
--ring-red-from: #a3271a;
--ring-red-to: #cb3a2a;
/*
* 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;
}
/* ── 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.14.0 — the wellness-strip "signature reveal" motion system.
*
* The whole moment plays ONCE per session (the strip toggles
* `data-revealed="true"` once, gated on a sessionStorage flag) so a
* background Apple-Health-sync refetch never re-triggers it. Three layers:
* 1. tile rise+fade (staggered left-to-right by an inline animation-delay),
* 2. the ring's spring-fill arc sweep (in score-ring.tsx, --ring-spring),
* 3. a conic SHEEN that sweeps the arc once (the "expensive" specular touch).
* A single prefers-reduced-motion block at the bottom zeroes all of it.
*/
@property --shimmer-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
: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);
}
}
/* The conic sheen overlay sits over the ring; opacity animates from 0 so a
browser without @property support (Safari <16.4 / FF <128) shows nothing
rather than a frozen highlight band. */
.wellness-ring-sheen {
position: absolute;
inset: 6%;
border-radius: 50%;
opacity: 0;
/* A specular highlight must always LIGHTEN. Under mix-blend overlay a
near-black light-mode `--card-foreground` would darken the tile into a
grey band, so the sheen rides a fixed white in both themes. */
background: radial-gradient(
circle at 50% 50%,
rgba(255, 255, 255, 0.72) 0%,
transparent 70%
);
mix-blend-mode: overlay;
pointer-events: none;
-webkit-mask: conic-gradient(
from var(--shimmer-angle),
transparent 0deg,
transparent 38deg,
#000 58deg,
transparent 80deg,
transparent 360deg
);
mask: conic-gradient(
from var(--shimmer-angle),
transparent 0deg,
transparent 38deg,
#000 58deg,
transparent 80deg,
transparent 360deg
);
}
[data-revealed="true"] .wellness-ring-sheen {
animation: ringSheen 1500ms var(--ring-ease) forwards;
animation-delay: calc(var(--reveal-delay, 0ms) + 550ms);
}
@keyframes ringSheen {
0% {
--shimmer-angle: 0deg;
opacity: 0;
}
15% {
opacity: 0.5;
}
85% {
opacity: 0.5;
}
100% {
--shimmer-angle: 360deg;
opacity: 0;
}
}
/* Token-tinted bloom behind the progress arc. The breathing pulse runs only
on the strongest (green-band) ring so the strip celebrates a good score
rather than jittering all five. `--ring-glow` is set inline per ring. */
.wellness-ring-arc {
filter: drop-shadow(
0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent)
);
}
/* Flat 2-D arc — no bloom, no pulse. The dashboard hero ring uses this so it
reads as calmly as the chart + tile surfaces around it. */
.wellness-ring-arc--flat {
filter: none;
}
.wellness-ring-arc--pulse {
/* Breathe a few times to celebrate a strong score, then settle — an
infinite loop would repaint the (non-compositable) drop-shadow filter
forever. Motion happens, then the surface goes calm. */
animation: ringGlow 3.4s ease-in-out 2s 3;
}
@keyframes ringGlow {
0%,
100% {
filter: drop-shadow(
0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent)
);
}
50% {
filter: drop-shadow(
0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent)
);
}
}
@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;
}
[data-revealed="true"] .wellness-ring-sheen {
animation: none;
opacity: 0;
}
.wellness-ring-arc,
.wellness-ring-arc--pulse {
filter: 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;
}
}
/* ── 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;
}
}