Merge pull request #420 from MBombeck/release/v1.27.11

Release v1.27.11 — consistency, colour, and locale fixes
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-05 22:50:54 +02:00
commit 4ea50c5d64
94 files changed
+968 -346

No files matched your search

+13
View File
@@ -2,6 +2,19 @@
## [Unreleased]
## [1.27.11] — 2026-07-05 — Consistency, colour, and locale fixes
### Fixed
- An explicitly chosen display language now sticks on every device, permanently. Two causes were fixed together: the server never consulted the profile's language when the locale cookie was missing, and Safari deletes script-written cookies after seven days — which silently flipped the UI back to the system language every week. The cookie is now set server-side and the profile choice wins whenever the cookie is gone.
- Every insights and dashboard card now shares one measured geometry: the same text edge, the same header-to-body distance, the same foreground colour for body prose. A measurement pass over all pages found and fixed a dozen drifted surfaces, including the "usual range" strip (rebuilt on the standard card) and the blood-pressure explainer.
- The dose ring on the dashboard paints in the medication hue used across the insights instead of green.
### Changed
- The last raw theme-palette colour utilities are gone — every surface reads from semantic tokens now, and the lint rule that guards this is set to error. A small brand token covers the few places that genuinely carry the brand accent, with computed AA contrast in both themes.
- Card slots are guarded against padding overrides by a new lint rule, keeping the spacing scale uniform going forward.
## [1.27.10] — 2026-07-05 — Two new check-ins: wellbeing and sleep
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.10
version: 1.27.11
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+20
View File
@@ -203,6 +203,26 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
state.cookies = state.cookies.filter((c) =>
["healthlog_session", "healthlog-locale", "hl_onboarding"].includes(c.name),
);
// v1.27.11 — pin the shared auth state to English via an explicit locale
// cookie. The server now honours `User.locale` when the cookie is absent
// (the ITP fix), and the locale-switch spec mirrors its German pick into
// the SHARED e2e user's profile — without this cookie every spec running
// after it would render German and the English-string assertions fail.
// The cookie sits at the top of the resolution ladder, so each spec
// context stays deterministically English; locale-switch overrides it
// inside its own context only.
const base = new URL(baseURL);
state.cookies = state.cookies.filter((c) => c.name !== "healthlog-locale");
state.cookies.push({
name: "healthlog-locale",
value: "en",
domain: base.hostname,
path: "/",
expires: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365,
httpOnly: false,
secure: false,
sameSite: "Lax",
});
await writeFile(STORAGE_STATE_PATH, JSON.stringify(state, null, 2));
await ctx.dispose();
console.log(
@@ -159,3 +159,36 @@ ruleTester.run("no-raw-palette-color (dracula check)", rule, {
},
],
});
ruleTester.run("no-raw-palette-color (dracula blind spots)", rule, {
valid: [
// Semantic replacements for the directional + paren-var forms.
{
code: 'const c = "border-l-destructive/70 bg-success border-success";',
filename: APP_FILE,
options: [{ checks: ["dracula"] }],
},
// Token references through var() stay legal.
{
code: 'const c = "shadow-[0_0_0_1px_color-mix(in_oklab,var(--success)_20%,transparent)]";',
filename: APP_FILE,
options: [{ checks: ["dracula"] }],
},
],
invalid: [
// Directional border form.
{
code: 'const c = "border-l-dracula-red/70";',
filename: APP_FILE,
options: [{ checks: ["dracula"] }],
errors: [{ messageId: "draculaUtility" }],
},
// Parenthesised CSS-var utility shorthand.
{
code: 'const c = "bg-(--dracula-green)";',
filename: APP_FILE,
options: [{ checks: ["dracula"] }],
errors: [{ messageId: "draculaUtility" }],
},
],
});
@@ -0,0 +1,112 @@
/**
* Unit tests for the `healthlog/spacing-scale` rule — pt-* / pb-* slot
* padding on CardHeader/CardContent is banned (the Card is gap-based;
* density lives on the Card itself). Covers modifier prefixes,
* cn()/ternary/template collection, the safe-area non-match, and the
* scope gating shared with the sibling rules.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../spacing-scale.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const APP_FILE = "/repo/src/components/insights/example.tsx";
ruleTester.run("spacing-scale", rule, {
valid: [
// Density on the Card itself is the sanctioned pattern.
{
code: '<Card className="gap-2 py-3 md:py-4"><CardContent>x</CardContent></Card>',
filename: APP_FILE,
},
// Slots without className, or with unrelated classes, are fine.
{ code: "<CardHeader />", filename: APP_FILE },
{
code: '<CardContent className="px-0 space-y-4" />',
filename: APP_FILE,
},
// Other paddings on slots are not this rule's business.
{
code: '<CardContent className="pl-2 pr-2" />',
filename: APP_FILE,
},
// Safe-area helpers are not scale steps and never match.
{
code: '<CardContent className="pb-safe" />',
filename: APP_FILE,
},
// pt-/pb- on arbitrary elements stays legal — the contract is about
// the gap-based Card slots only.
{ code: '<div className="pb-2" />', filename: APP_FILE },
// Outside the enforced roots nothing matches.
{
code: '<CardHeader className="pb-2" />',
filename: "/repo/scripts/tool.tsx",
},
// Test files are exempt.
{
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
],
invalid: [
{
code: '<CardHeader className="pb-2" />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
{
code: '<CardContent className="pt-0" />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Modifier-prefixed and fractional steps.
{
code: '<CardHeader className="md:pb-1.5" />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Arbitrary-value padding.
{
code: '<CardContent className="pt-[10px]" />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Collected through cn() args…
{
code: '<CardHeader className={cn("pb-3", extra)} />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// …ternaries…
{
code: '<CardContent className={dense ? "pt-2" : "pt-4"} />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }, { messageId: "slotPadding" }],
},
// …and template literals.
{
code: "<CardHeader className={`pb-2 ${extra}`} />",
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Member-expression component tails match too.
{
code: '<UI.CardHeader className="pb-2" />',
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
],
});
+6 -3
View File
@@ -6,6 +6,7 @@
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
*/
"use strict";
@@ -14,6 +15,7 @@ const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
const noRawPaletteColor = require("./no-raw-palette-color.js");
const spacingScale = require("./spacing-scale.js");
module.exports = {
rules: {
@@ -21,10 +23,11 @@ module.exports = {
"safe-fetch-required": safeFetchRequired,
"api-fetch-required": apiFetchRequired,
"no-raw-palette-color": noRawPaletteColor,
"spacing-scale": spacingScale,
// Same module as `no-raw-palette-color`, registered under a second
// name so the flat config can run the `dracula` check at a different
// severity (warn) than the error-level checks. See the staged plan in
// the rule header.
// name so the flat config can run the `dracula` check as its own
// named rule (now error-level too; the staged warn phase ended with
// the semantic sweep). See the rule header.
"no-dracula-utility": noRawPaletteColor,
},
};
@@ -28,14 +28,13 @@
* before any stylesheet loads, so it must stay a literal).
* - `arbitrary-value` (error) — Tailwind arbitrary colour values
* (`bg-[#…]`, `text-[oklch(…)]`). `…-[var(--token)]` never matches.
* - `dracula` (WARN, staged) — raw `*-dracula-*` utilities. ~250
* legacy sites predate the token system; the light-theme overrides in
* `globals.css` defuse their AA failures, so the check ships as a
* warning first (registered separately as
* `healthlog/no-dracula-utility`). It moves to error once the
* semantic sweep (status meaning → `text-success/warning/info/
* destructive`, brand use → `--brand-*` tokens) has retired the
* legacy sites.
* - `dracula` (error, registered separately as
* `healthlog/no-dracula-utility`) — raw `*-dracula-*` utilities.
* The semantic sweep retired every legacy site: status meaning →
* `text-success/warning/info/destructive`, purple → `primary`,
* pink → the `--brand-pink` token. `var(--dracula-*)` references in
* chart code are token references, not utilities, and never match —
* the light-theme overrides in `globals.css` keep them AA.
*
* The `checks` option selects which detectors run, so the flat config can
* register the same module twice at different severities (see
@@ -78,9 +77,12 @@ const RAW_PALETTE_RE =
// Raw `dracula-*` utility: the pre-token palette accessed directly
// (`text-dracula-green`, `bg-dracula-orange/15`). Same prefix set and
// modifier tolerance as the palette check.
// modifier tolerance as the palette check, plus the directional border
// forms (`border-l-dracula-red`) and the parenthesised CSS-var utility
// shorthand (`bg-(--dracula-green)`) — both bypassed the plain prefix
// match during the staged-warning phase.
const DRACULA_RE =
/\b(?:text|bg|border|ring|stroke|fill|from|to|via)-dracula-[a-z]+\b/;
/\b(?:text|bg|border(?:-[lrtbxyse])?|ring|stroke|fill|from|to|via)-(?:dracula-[a-z]+\b|\(--dracula-[a-z]+\))/;
// Tailwind arbitrary colour value: `bg-[#…]`, `text-[rgb(…)]`,
// `border-[oklch(…)]`. Matching on the bracket-open plus a colour-literal
+169
View File
@@ -0,0 +1,169 @@
/**
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
* gap-based Card slots.
*
* The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
*
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
* seen. Non-numeric suffixes (`pb-safe`) never match.
*
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
* gating as the sibling rules. `src/components/ui/card.tsx` itself
* composes raw `<div>`s and is untouched by construction.
*
* There is deliberately NO allowlist: no current site has a documented
* load-bearing reason for a slot padding override. If one ever appears,
* it carries an inline `eslint-disable-next-line` with the reason —
* visible in review — rather than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
*/
"use strict";
// Only enforce inside the app UI roots.
const ENFORCED_ROOTS = ["src/components/", "src/app/"];
// A pt-/pb- utility with a numeric step, `px`, or an arbitrary value,
// optionally behind modifier prefixes (`md:`, `hover:`, `[.border-b]:`).
// `pb-safe` (safe-area helpers) and word-suffixed utilities never match.
const SLOT_PADDING_RE =
/(?:^|\s)(?:[^\s"']*:)*(p[tb]-(?:\d+(?:\.\d+)?|px|\[[^\]]+\]))(?=\s|$)/;
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** Resolve the rendered-element name (`CardHeader`, `UI.CardHeader` → tail). */
function elementName(openingElement) {
const nameNode = openingElement.name;
if (nameNode.type === "JSXIdentifier") return nameNode.name;
if (
nameNode.type === "JSXMemberExpression" &&
nameNode.property.type === "JSXIdentifier"
) {
return nameNode.property.name;
}
return null;
}
/**
* Collect every static string reachable inside the className attribute
* value: plain literals, template chunks, and the arguments of helper
* calls (`cn(…)`, `clsx(…)`), conditionals and logical fallbacks.
*/
function collectStrings(node, out) {
if (!node) return;
switch (node.type) {
case "Literal":
if (typeof node.value === "string") out.push({ node, text: node.value });
return;
case "TemplateLiteral":
for (const quasi of node.quasis) {
out.push({ node: quasi, text: quasi.value.cooked ?? quasi.value.raw });
}
for (const expr of node.expressions) collectStrings(expr, out);
return;
case "CallExpression":
for (const arg of node.arguments) collectStrings(arg, out);
return;
case "ConditionalExpression":
collectStrings(node.consequent, out);
collectStrings(node.alternate, out);
return;
case "LogicalExpression":
collectStrings(node.left, out);
collectStrings(node.right, out);
return;
case "ArrayExpression":
for (const el of node.elements) collectStrings(el, out);
return;
case "JSXExpressionContainer":
collectStrings(node.expression, out);
return;
default:
return;
}
}
/** @type {import("eslint").Rule.RuleModule} */
const spacingScaleRule = {
meta: {
type: "problem",
docs: {
description:
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
return {
JSXOpeningElement(node) {
const name = elementName(node);
if (!name || !SLOT_NAMES.has(name)) return;
const classNameAttr = node.attributes.find(
(attr) =>
attr.type === "JSXAttribute" &&
attr.name?.type === "JSXIdentifier" &&
attr.name.name === "className",
);
if (!classNameAttr) return;
const strings = [];
collectStrings(classNameAttr.value, strings);
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
});
}
}
},
};
},
};
module.exports = spacingScaleRule;
+11 -5
View File
@@ -59,11 +59,17 @@ const eslintConfig = defineConfig([
"error",
{ checks: ["palette", "color-props", "arbitrary-value"] },
],
// Staged: raw `*-dracula-*` utilities warn for now — ~250 legacy
// sites predate the token system and the light-theme overrides in
// globals.css keep them readable. Moves to error once the semantic
// sweep has retired the legacy sites (see the rule header).
"healthlog/no-dracula-utility": ["warn", { checks: ["dracula"] }],
// The semantic sweep retired every raw `*-dracula-*` utility site
// (status meaning → success/warning/info/destructive, purple →
// primary, pink → the --brand-pink token), so the staged warning is
// now a hard error. `var(--dracula-*)` references in chart code are
// token references, not utilities, and stay legal — the light-theme
// overrides in globals.css keep them AA on both themes.
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
// fights the gap and re-opens the sl-001 drift class. Density lives
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
"healthlog/spacing-scale": "error",
},
},
]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.10",
"version": "1.27.11",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.27.10";
/* @sw-version-fallback */ "v1.27.11";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+12
View File
@@ -20,11 +20,13 @@
* holds a value the resolver would later have to defend against.
*/
import { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { locales, type Locale } from "@/lib/i18n/config";
import { setLocaleCookie } from "@/lib/i18n/locale-cookie";
export const dynamic = "force-dynamic";
@@ -58,5 +60,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
annotate({ meta: { locale_next: locale } });
}
// Mirror the accepted locale into the `healthlog-locale` cookie via
// Set-Cookie. The client's own `document.cookie` write is capped at 7
// days by Safari ITP; the server-set copy is not, so an explicit choice
// survives on every device. Runs on the idempotent path too — the
// mount-time backfill hitting this route is exactly the weekly refresh
// that keeps the cookie alive. Harmless no-op payload for Bearer
// callers (native clients ignore cookies).
const cookieStore = await cookies();
setLocaleCookie(cookieStore, locale);
return apiSuccess({ locale });
});
+1 -1
View File
@@ -224,7 +224,7 @@ function CoachConversationsBody() {
className={cn(
"shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red",
"text-destructive hover:text-destructive",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
+28 -7
View File
@@ -104,6 +104,17 @@
--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
@@ -160,6 +171,14 @@
--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
@@ -337,13 +356,15 @@
--chart-5: #a34d14;
/*
* Light-mode (Alucard) overrides for the raw `--dracula-*` primitives.
* ~250 legacy `text-dracula-*` / opacity-suffixed `bg-dracula-*` call
* sites still read these directly; 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)), so every legacy utility inherits a readable tone until the
* semantic sweep retires it.
* 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 */
+19 -12
View File
@@ -9,6 +9,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { useChartDomainStats } from "@/hooks/use-chart-domain-stats";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
@@ -189,18 +190,24 @@ export default function InsightsBlutdruckPage() {
const pp = Math.round(sbp - dbp);
const map = Math.round((sbp + 2 * dbp) / 3);
return (
<p
data-slot="bp-derived-caption"
className="text-muted-foreground text-sm leading-relaxed"
>
{t("insights.subPage.bpDerived.pulsePressure", {
value: pp,
})}{" "}
{t("insights.subPage.bpDerived.meanArterialPressure", {
value: map,
})}{" "}
{t("insights.subPage.bpDerived.caveat")}
</p>
// Card shell at the compact density so the explainer shares the
// spine's left text edge (the bare paragraph sat at the container
// edge, ~16–24 px left of every neighbouring card's text) — and
// foreground, not muted: multi-sentence explainer prose is
// content, muted stays reserved for meta.
<Card data-slot="bp-derived-caption" className="gap-2 py-3 md:py-4">
<CardContent>
<p className="text-sm leading-relaxed">
{t("insights.subPage.bpDerived.pulsePressure", {
value: pp,
})}{" "}
{t("insights.subPage.bpDerived.meanArterialPressure", {
value: map,
})}{" "}
{t("insights.subPage.bpDerived.caveat")}
</p>
</CardContent>
</Card>
);
})()}
+1 -1
View File
@@ -210,7 +210,7 @@ export default function InsightsMedikamentePage() {
? t("medications.categoryVitamin")
: t("medications.categoryOther");
return (
<Card key={med.id} className="gap-2 py-4 md:gap-3 md:py-5">
<Card key={med.id} className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader icon={Pill} title={med.name} />
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
+2 -20
View File
@@ -1,32 +1,14 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { headers, cookies } from "next/headers";
import { headers } from "next/headers";
import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { allMessages } from "@/lib/i18n/shared-resolve";
async function resolveInitialLocale(): Promise<Locale> {
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
// fall back to the default so a locale hiccup never crashes the root
// layout into global-error.tsx.
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
return cookieLocale as Locale;
}
const headerList = await headers();
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
} catch {
return "en";
}
}
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
@@ -89,9 +89,9 @@ describe("CoachFeedbackSection", () => {
// Two bucket rows in the table.
const matches = (html.match(/coach-feedback-bucket/g) ?? []).length;
expect(matches).toBe(2);
// Helpful-rate column carries the band tint.
expect(html).toContain("text-dracula-green"); // 80% bucket
expect(html).toContain("text-dracula-yellow"); // 50% bucket
// Helpful-rate column carries the band tint (semantic urgency ramp).
expect(html).toContain("text-success"); // 80% bucket
expect(html).toContain("text-warning"); // 50% bucket
// Numbers visible in the markup.
expect(html).toContain("80%");
expect(html).toContain("50%");
+8 -5
View File
@@ -122,7 +122,7 @@ export interface ApiTokenInfo {
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
<Badge className="border-success/30 bg-success/15 text-success">
{t("admin.configured")}
</Badge>
);
@@ -488,11 +488,14 @@ export function useSystemStatus() {
}
/** Helpful-rate tint shared by the AI-quality + Coach-feedback tables.
* Green ≥80 %, yellow ≥50 %, orange below. */
* The standard urgency ramp (the medication-status-pill precedent):
* ≥80 % success, ≥50 % warning, below destructive — a sub-50 % helpful
* rate is a genuine quality alarm, and the percent label always carries
* the exact value alongside the tint. */
export function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
if (rate >= 0.8) return "text-success";
if (rate >= 0.5) return "text-warning";
return "text-destructive";
}
export interface PublicVersion {
+4 -4
View File
@@ -46,10 +46,10 @@ interface FeedbackSummary {
}
const SEVERITY_TINT: Record<string, string> = {
info: "text-dracula-cyan",
suggestion: "text-dracula-purple",
important: "text-dracula-orange",
urgent: "text-dracula-red",
info: "text-info",
suggestion: "text-primary",
important: "text-warning",
urgent: "text-destructive",
};
export function AiQualitySection() {
@@ -92,9 +92,7 @@ function TokenStatusBadge({
);
}
return (
<Badge
className={`bg-dracula-green/15 text-dracula-green shrink-0 ${sizeClass}`}
>
<Badge className={`bg-success/15 text-success shrink-0 ${sizeClass}`}>
{t("common.active")}
</Badge>
);
@@ -75,12 +75,12 @@ function levelIcon(level: LogLevel) {
case "error":
return <AlertCircle className="text-destructive h-4 w-4" />;
case "warn":
return <AlertTriangle className="text-dracula-yellow h-4 w-4" />;
return <AlertTriangle className="text-warning h-4 w-4" />;
case "debug":
return <Info className="text-muted-foreground h-4 w-4" />;
case "info":
default:
return <CheckCircle2 className="text-dracula-green h-4 w-4" />;
return <CheckCircle2 className="text-success h-4 w-4" />;
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ export function DangerZoneSection() {
{wipeMsg && (
<p
className={`mt-3 pl-7 text-sm ${
wipeAllData.isError ? "text-destructive" : "text-dracula-green"
wipeAllData.isError ? "text-destructive" : "text-success"
}`}
>
{wipeMsg}
+11 -11
View File
@@ -145,20 +145,20 @@ const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
// Quiet tinted chips with a colour dot — the dot carries the state at
// a glance, the tint stays low so a table of mixed states reads calm.
active: {
dot: "bg-dracula-green",
chip: "border-dracula-green/30 bg-dracula-green/10 text-dracula-green",
dot: "bg-success",
chip: "border-success/30 bg-success/10 text-success",
},
expired: {
dot: "bg-muted-foreground/60",
chip: "border-border bg-muted/40 text-muted-foreground",
},
exhausted: {
dot: "bg-dracula-cyan",
chip: "border-dracula-cyan/30 bg-dracula-cyan/10 text-dracula-cyan",
dot: "bg-info",
chip: "border-info/30 bg-info/10 text-info",
},
revoked: {
dot: "bg-dracula-red",
chip: "border-dracula-red/30 bg-dracula-red/10 text-dracula-red",
dot: "bg-destructive",
chip: "border-destructive/30 bg-destructive/10 text-destructive",
},
};
@@ -451,7 +451,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-dracula-red hover:text-dracula-red"
className="text-destructive hover:text-destructive"
aria-label={t("admin.invites.revokeAria")}
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
@@ -519,7 +519,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-dracula-red hover:text-dracula-red"
className="text-destructive hover:text-destructive"
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
>
@@ -582,7 +582,7 @@ export function InviteTokensSection() {
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
@@ -647,7 +647,7 @@ export function InviteTokensSection() {
</DialogHeader>
{/* The ticket — dashed edge marks the one-time nature. */}
<div
className="border-dracula-purple/40 bg-background rounded-xl border border-dashed p-4"
className="border-primary/40 bg-background rounded-xl border border-dashed p-4"
data-testid="admin-invites-minted"
>
<div className="flex flex-col items-center gap-4">
@@ -671,7 +671,7 @@ export function InviteTokensSection() {
>
{copied ? (
<Check
className="text-dracula-green size-4"
className="text-success size-4"
aria-hidden="true"
/>
) : (
@@ -464,7 +464,7 @@ export function LoginOverviewSection() {
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
<CheckCircle2 className="text-success h-4 w-4" />
)}
</td>
<td
@@ -503,7 +503,7 @@ export function LoginOverviewSection() {
</span>
{copiedIp === entry.id ? (
<Check
className="text-dracula-green h-3 w-3 shrink-0"
className="text-success h-3 w-3 shrink-0"
aria-hidden="true"
/>
) : (
@@ -115,7 +115,7 @@ export function RecentAuditPreview() {
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
<CheckCircle2 className="text-success h-4 w-4" />
)}
</span>
<span
+6 -10
View File
@@ -70,7 +70,7 @@ export function SystemStatusSection() {
}
className={
status.database === "connected"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -108,9 +108,7 @@ export function SystemStatusSection() {
: t("admin.workerStopped")
}
className={
status.worker.running
? "text-dracula-green"
: "text-destructive"
status.worker.running ? "text-success" : "text-destructive"
}
/>
{status.worker.lastReminderCheck && (
@@ -131,7 +129,7 @@ export function SystemStatusSection() {
}
className={
status.integrations.umami.enabled
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -147,7 +145,7 @@ export function SystemStatusSection() {
}
className={
status.integrations.glitchtip.enabled
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -157,7 +155,7 @@ export function SystemStatusSection() {
icon={BellRing}
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
className="text-success"
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
@@ -175,9 +173,7 @@ export function SystemStatusSection() {
})
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
version.offlineGeoEnabled ? "text-success" : "text-warning"
}
/>
)}
@@ -61,7 +61,7 @@ export function SystemStatusSummary() {
}
className={
status.database === "connected"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -74,7 +74,7 @@ export function SystemStatusSummary() {
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-dracula-green" : "text-destructive"
status.worker.running ? "text-success" : "text-destructive"
}
/>
<StatusItem
@@ -112,9 +112,7 @@ export function SystemStatusSummary() {
})
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
version.offlineGeoEnabled ? "text-success" : "text-warning"
}
/>
)}
@@ -190,7 +190,7 @@ export function UserManagementSection() {
<Button
variant="ghost"
size="sm"
className={`min-h-11 min-w-11 px-3 text-xs ${u.mfaEnforced ? "text-dracula-green" : ""}`}
className={`min-h-11 min-w-11 px-3 text-xs ${u.mfaEnforced ? "text-success" : ""}`}
onClick={() =>
updateUser.mutate({
id: u.id,
@@ -554,7 +554,7 @@ export function UserManagementSection() {
</div>
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
@@ -97,7 +97,7 @@ export function VersionTileSection() {
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
@@ -106,7 +106,7 @@ export function VersionTileSection() {
})}
</a>
) : upToDate ? (
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
<span className="text-success inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
@@ -315,9 +315,7 @@ export function MedicationComplianceChart({
// mood tiles.
const trendColor = ((): string => {
if (!trend || trend.direction === "stable") return "text-muted-foreground";
return trend.direction === "up"
? "text-dracula-green"
: "text-dracula-orange";
return trend.direction === "up" ? "text-success" : "text-warning";
})();
const TrendIcon = !trend
@@ -172,7 +172,7 @@ export function CustomMetricDetail({
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? t("customMetrics.detail.title")}
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
@@ -262,9 +262,7 @@ export function CustomMetricDetail({
</div>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{isLoading ? (
<Card>
@@ -395,12 +395,20 @@ describe("<DashboardHero> — ring row (v1.27.7)", () => {
expect(rings).toHaveLength(3);
// Labels (de locale): the derived ring reuses its wellness-strip
// title; the dose ring names today's tally and shows it as
// taken/scheduled over the constant green arc — never the band
// gradient (a pending morning dose is not an alert state).
// taken/scheduled over the constant med-family arc (`--primary`,
// the hue the medication surfaces in Insights paint) — never the
// band gradient (a pending morning dose is not an alert state) and
// never the ring system's green (reserved for band semantics).
expect(html).toContain("Bereitschaft");
expect(html).toContain("Dosen heute");
expect(html).toContain(">1/3<");
expect(html).toContain("var(--ring-green-from)");
// Window the assertion to the dose ring's own SVG (its gradient defs
// close with the svg) so the trailing health-score ring's band-green
// gradient can't mask a regression.
const medIdx = html.indexOf('data-ring="MED_COMPLIANCE"');
const doseRing = html.slice(medIdx, html.indexOf("</svg>", medIdx));
expect(doseRing).toContain("var(--primary)");
expect(doseRing).not.toContain("var(--ring-green-from)");
expect(html).not.toContain("var(--ring-yellow-from)");
});
@@ -101,16 +101,18 @@ const CTA_LABEL_KEY: Partial<Record<DashboardVerdictVariant, string>> = {
/**
* Per-ring hue for the selected score rings — the wellness-strip
* vocabulary, so every hero ring paints EXACTLY the hue its insights
* sibling paints. MED_COMPLIANCE carries no per-metric hue entry: the
* dose ring pins the ring system's green arc (`band="green"`, constant)
* — the same green family the medication compliance surfaces in
* Insights paint for a taken dose — instead of a band gradient that
* would flash yellow/red over pending morning doses.
* sibling paints. MED_COMPLIANCE rides the `meds` hue: the medication
* family in Insights paints `--primary` on every surface (compliance
* bars, compliance trend line, drug-level / dose-strength curves), so
* the dose ring holds that one constant tone — never a band gradient
* that would flash yellow/red over pending morning doses, and not the
* green arc the ring system reserves for band semantics.
*/
const RING_HUE_BY_ID: Partial<Record<ScoreRingId, RingHue>> = {
READINESS: "readiness",
RECOVERY_SCORE: "recovery",
SLEEP_SCORE: "sleep",
MED_COMPLIANCE: "meds",
};
/** Per-ring label key — reuses the existing score labels; the dose ring
@@ -328,13 +330,19 @@ export function DashboardHero({
className="flex shrink-0 items-center"
>
{/* Dose ring — today's tally ("1/3") over the constant
green arc; the arc still sweeps on the 0..100
progress `score`. Falls through to the score render
when a cached pre-doses snapshot carries no tally. */}
med-family arc (`hue="meds"` = --primary, the tone
every medication surface in Insights paints); the arc
still sweeps on the 0..100 progress `score`.
`band="green"` stays as the stable data-band anchor —
a pending morning dose is not an alert state — while
the hue owns the paint. Falls through to the score
render when a cached pre-doses snapshot carries no
tally. */}
{ring.id === "MED_COMPLIANCE" && ring.doses ? (
<ScoreRing
score={ring.score}
band="green"
hue="meds"
valueText={`${ring.doses.taken}/${ring.doses.scheduled}`}
ariaLabel={t("dashboard.hero.ringDosesAria", {
taken: ring.doses.taken,
@@ -103,7 +103,9 @@ export function RecentWorkoutsTile() {
return (
<div
data-slot="recent-workouts-tile"
className={cn("bg-card border-border space-y-3 rounded-xl border p-4")}
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
@@ -111,7 +111,7 @@ export function RecentAchievementsCard() {
return (
<div
data-slot="recent-achievements-card"
className="bg-card border-border space-y-3 rounded-xl border p-4"
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
@@ -62,22 +62,24 @@ describe("<ConfidenceMeter> bars variant (default)", () => {
expect(lit).toBe(2);
});
it("uses green band class at value>=80", () => {
it("uses the success tint at value>=80", () => {
const html = render(<ConfidenceMeter value={85} />);
expect(html).toContain('data-confidence-band="high"');
expect(html).toMatch(/dracula-green/);
expect(html).toMatch(/bg-success/);
});
it("uses yellow band class at value 50..79", () => {
it("uses the caution tint at value 50..79", () => {
const html = render(<ConfidenceMeter value={65} />);
expect(html).toContain('data-confidence-band="medium"');
expect(html).toMatch(/dracula-yellow/);
expect(html).toMatch(/bg-warning/);
});
it("uses orange band class at value 25..49", () => {
// Medium and low share the caution tint (coverage-meter precedent);
// the band attribute + lit-bar count carry the distinction.
it("uses the caution tint at value 25..49", () => {
const html = render(<ConfidenceMeter value={35} />);
expect(html).toContain('data-confidence-band="low"');
expect(html).toMatch(/dracula-orange/);
expect(html).toMatch(/bg-warning/);
});
it("renders the 'draft' pill INSTEAD of bars when value<25", () => {
@@ -148,13 +148,13 @@ describe("<RecommendationsGrid>", () => {
rec("d", "info", "info-rec"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Each card gets a Dracula-token border-l class. The exact class
// Each card gets a semantic-token border-l class. The exact class
// varies by severity; we assert at least one of each is in the
// output.
expect(html).toMatch(/border-l-dracula-red/);
expect(html).toMatch(/border-l-dracula-orange/);
expect(html).toMatch(/border-l-dracula-purple/);
expect(html).toMatch(/border-l-dracula-cyan/);
expect(html).toMatch(/border-l-destructive/);
expect(html).toMatch(/border-l-warning/);
expect(html).toMatch(/border-l-primary/);
expect(html).toMatch(/border-l-info/);
});
it("renders nothing when recs is empty", () => {
@@ -68,9 +68,9 @@ describe("<SuggestedPrompts>", () => {
expect(matches.length).toBe(2);
});
it("uses Dracula purple chip styling tokens", () => {
it("uses the primary-token chip styling", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toMatch(/border-dracula-purple\/18/);
expect(html).toMatch(/border-primary\/18/);
});
it("invokes onPick with the localised prompt string when a chip is clicked", () => {
@@ -726,7 +726,7 @@ export function CoachConversation({
) : null}
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br shadow-sm"
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br shadow-sm"
>
<Sparkles className="text-background size-4" />
</div>
@@ -61,7 +61,7 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 -z-10",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--dracula-purple)_14%,transparent),transparent_70%)]",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--primary)_14%,transparent),transparent_70%)]",
)}
/>
@@ -70,7 +70,7 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
<div
aria-hidden="true"
className={cn(
"from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"from-primary to-brand-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-500",
)}
>
@@ -428,7 +428,7 @@ export function CoachInput({
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
? "text-brand-pink bg-brand-pink/10 hover:text-brand-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
@@ -553,7 +553,7 @@ export function CoachInput({
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"shadow-sm transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
"focus-within:border-primary/50 focus-within:ring-primary/15 focus-within:bg-background focus-within:ring-2",
// Both surfaces share ONE baseline row: the drawer flanks the
// textarea with mic (left) + send (right); the page leads with a
// `+` actions menu, then the textarea, then mic + send. `items-end`
@@ -601,7 +601,7 @@ export function CoachInput({
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
// Keep the placeholder a single line on narrow phones: a long
// hint used to wrap to two lines inside the one-row composer and
@@ -37,7 +37,7 @@ import { TypingDots } from "./message-thread";
/** Shared quiet text-button styling for the per-question actions. */
const guidedActionClass = cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
);
@@ -45,7 +45,7 @@ function GuidedAvatar({ icon: Icon }: { icon: typeof ClipboardCheck }) {
return (
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Icon className="text-background size-3.5" />
</div>
@@ -66,9 +66,8 @@ function ProgressDots({ current, total }: { current: number; total: number }) {
key={i}
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current &&
"ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 < current && "bg-primary",
i + 1 === current && "ring-primary bg-primary/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
@@ -125,7 +124,7 @@ export function GuidedQuestionBubble({
<div className="flex max-w-[calc(80%-2.625rem)] min-w-0 flex-col gap-1.5">
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"border-primary/30 bg-primary/10 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
@@ -134,7 +133,7 @@ export function GuidedQuestionBubble({
<>
<p
data-slot="coach-guided-progress"
className="text-dracula-purple mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
className="text-primary mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
>
{t("insights.coach.guided.progress", {
current: progress.current,
@@ -208,7 +207,7 @@ export function GuidedSummaryBubble({
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"border-primary/30 bg-primary/10 text-foreground",
"max-w-[calc(80%-2.625rem)] min-w-0 rounded-xl rounded-tl-sm border px-3.5 py-2.5",
)}
>
@@ -226,8 +225,8 @@ export function GuidedSummaryBubble({
href="/settings/ai"
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"focus-visible:ring-dracula-purple/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
"text-primary mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"focus-visible:ring-primary/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
@@ -44,14 +44,14 @@ export function GuidedQuestionsCard({
<div
data-slot="coach-guided-offer"
className={cn(
"border-dracula-purple/30 from-dracula-purple/10 to-dracula-pink/5",
"border-primary/30 from-primary/10 to-brand-pink/5",
"mb-2 rounded-lg border bg-gradient-to-r px-3 py-2.5",
)}
>
<div className="flex items-start gap-2.5">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<MessageCircleQuestion className="text-background size-3.5" />
</div>
@@ -71,8 +71,8 @@ export function GuidedQuestionsCard({
disabled={disabled}
onClick={onStart}
className={cn(
"bg-dracula-purple/15 text-dracula-purple inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-dracula-purple/25 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"bg-primary/15 text-primary inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-primary/25 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -85,7 +85,7 @@ export function GuidedQuestionsCard({
onClick={onLater}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -98,7 +98,7 @@ export function GuidedQuestionsCard({
onClick={onDismissAll}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -151,7 +151,7 @@ export function HistoryRail({
"group flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-dracula-purple/15 text-foreground"
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
@@ -186,7 +186,7 @@ export function HistoryRail({
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming && "text-dracula-red hover:text-dracula-red",
isConfirming && "text-destructive hover:text-destructive",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
@@ -181,14 +181,14 @@ export function placeInterleaved(
* is mixed down so the bar reads as a hairline accent, not a hard edge.
*/
export const COACH_SCROLLBAR = cn(
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_30%,transparent)_transparent] [scrollbar-width:thin]",
"[scrollbar-color:color-mix(in_srgb,var(--primary)_30%,transparent)_transparent] [scrollbar-width:thin]",
"[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:border-[3px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-clip-content",
"[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_30%,transparent)]",
"hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_45%,transparent)]",
"[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--primary)_30%,transparent)]",
"hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--primary)_45%,transparent)]",
);
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
@@ -660,7 +660,7 @@ function CopyMessageButton({
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
@@ -897,7 +897,7 @@ export function MessageThread({
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-5" />
</div>
@@ -1257,7 +1257,7 @@ function ChatBubble({
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
{providerType === "refusal" ? (
<Bot className="text-background size-3.5" />
@@ -1545,7 +1545,7 @@ function RememberUserMessage({ content }: { content: string }) {
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
>
<Check className="text-dracula-green size-3" aria-hidden="true" />
<Check className="text-success size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
@@ -1616,7 +1616,7 @@ export function TypingDots({ label }: { label: string }) {
{[0, 1, 2].map((i) => (
<span
key={i}
className="bg-dracula-purple/70 size-1.5 animate-bounce rounded-full motion-reduce:animate-none"
className="bg-primary/70 size-1.5 animate-bounce rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
/>
))}
@@ -64,7 +64,7 @@ export function ReminderSuggestionCard({
data-slot="coach-reminder-suggestion-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
settled === "accepted"
? "coach.reminderSuggestion.accepted"
@@ -89,7 +89,7 @@ export function ReminderSuggestionCard({
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<BellPlus className="text-background size-3.5" />
</div>
@@ -111,7 +111,7 @@ export function ReminderSuggestionCard({
onClick={() => act.mutate("accept")}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
@@ -83,10 +83,7 @@ export function ScopeHintBadge({
"text-xs font-medium",
)}
>
<Icon
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{prefix}</span>
</span>
@@ -76,7 +76,7 @@ export function SelfContextAdoptOffer({
<div
data-slot="coach-self-context-adopt"
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 mb-2 flex items-center gap-2 rounded-lg border px-3 py-2",
"border-primary/30 bg-primary/10 mb-2 flex items-center gap-2 rounded-lg border px-3 py-2",
)}
>
{settled ? (
@@ -85,7 +85,7 @@ export function SelfContextAdoptOffer({
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check
className="text-dracula-green size-3.5 shrink-0"
className="text-success size-3.5 shrink-0"
aria-hidden="true"
/>
{t(
@@ -97,7 +97,7 @@ export function SelfContextAdoptOffer({
) : (
<>
<BookmarkPlus
className="text-dracula-purple size-3.5 shrink-0"
className="text-primary size-3.5 shrink-0"
aria-hidden="true"
/>
<p className="text-muted-foreground min-w-0 flex-1 text-xs">
@@ -109,8 +109,8 @@ export function SelfContextAdoptOffer({
disabled={adopt.isPending}
onClick={() => adopt.mutate()}
className={cn(
"text-dracula-purple inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"text-primary inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -132,7 +132,7 @@ export function SelfContextAdoptOffer({
}}
className={cn(
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-3" aria-hidden="true" />
@@ -86,7 +86,7 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-dracula-cyan/25 text-dracula-cyan/90",
"border-info/25 text-info/90",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-[11px] leading-none",
)}
@@ -139,10 +139,10 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
{activeScopeLabel ? (
<div
data-slot="coach-sources-active-scope"
className="border-dracula-purple/30 bg-dracula-purple/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
className="border-primary/30 bg-primary/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
>
<Target
className="text-dracula-purple size-3.5 shrink-0"
className="text-primary size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 truncate">
@@ -50,7 +50,7 @@ export function SuggestedActionCard({
data-slot="coach-suggested-action-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
settled === "applied"
? "coach.suggestedAction.applied"
@@ -77,7 +77,7 @@ export function SuggestedActionCard({
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-info mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-3.5" />
</div>
@@ -102,7 +102,7 @@ export function SuggestedActionCard({
onClick={() => apply.mutate()}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
+5 -1
View File
@@ -12,5 +12,9 @@ export type ConfidenceBand = "low" | "moderate" | "high";
export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
high: "border-success/40 bg-success/10 text-success",
moderate: "border-warning/40 bg-warning/10 text-warning",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
// Neutral (non-status) tier. The former `*-dracula-comment/*` classes
// had no generated utility (`--color-dracula-comment` was never in the
// @theme block) and silently painted nothing; border/muted is the real
// neutral vocabulary.
low: "border-border bg-muted/50 text-muted-foreground",
};
+11 -7
View File
@@ -47,22 +47,26 @@ function litBarsFor(value: number): number {
return lit;
}
// Semantic severities only (the coverage-meter precedent): medium and
// low both ride the caution `--warning` — the lit-bar count and the
// numeric aria-label carry the medium↔low distinction, never colour
// alone.
const BAR_LIT_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "bg-dracula-green",
medium: "bg-dracula-yellow",
low: "bg-dracula-orange",
high: "bg-success",
medium: "bg-warning",
low: "bg-warning",
};
const RING_STROKE_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "stroke-dracula-green",
medium: "stroke-dracula-yellow",
low: "stroke-dracula-orange",
high: "stroke-success",
medium: "stroke-warning",
low: "stroke-warning",
};
function DraftPill({ ariaLabel }: { ariaLabel: string }) {
@@ -72,7 +76,7 @@ function DraftPill({ ariaLabel }: { ariaLabel: string }) {
data-confidence-band="draft"
role="img"
aria-label={ariaLabel}
className="bg-dracula-red/10 text-dracula-red border-dracula-red/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.recommendation.confidenceDraft")}
</span>
+7 -6
View File
@@ -51,9 +51,9 @@ interface CorrelationCardProps {
}
const TONE_BAR_CLASSNAME: Record<CorrelationResult["kind"], string> = {
"bp-compliance": "bg-dracula-pink",
"mood-pulse": "bg-dracula-cyan",
"weight-weekday": "bg-dracula-purple",
"bp-compliance": "bg-brand-pink",
"mood-pulse": "bg-info",
"weight-weekday": "bg-primary",
};
const TITLE_KEY: Record<CorrelationResult["kind"], string> = {
@@ -208,12 +208,13 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
}
function tonesByKind(kind: CorrelationResult["kind"]): string {
// Keep in step with TONE_BAR_CLASSNAME above — same token per kind.
switch (kind) {
case "bp-compliance":
return "var(--dracula-pink)";
return "var(--brand-pink)";
case "mood-pulse":
return "var(--dracula-cyan)";
return "var(--info)";
case "weight-weekday":
return "var(--dracula-purple)";
return "var(--primary)";
}
}
@@ -8,6 +8,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
@@ -109,28 +110,35 @@ export function CoachReadStrip({
// fires defensively.)
if (!baselineLine && !driverLine) return null;
// Standard card anatomy — the same TileHeader-led shape as every other
// insights card (foreground icon + title), instead of the former inline
// mini-icon strip that read as chrome outside the card system. Body prose
// in the regular foreground; muted stays reserved for meta lines.
// Standard card anatomy — the real Card + TileHeader + CardContent
// primitives at the compact density (the metric-stat-strip reference),
// so background, radius, icon spacing and the left text edge match
// every other insights tile. The former hand-rolled shell
// (`bg-card/60 … px-4 py-3.5`) painted a translucent background and a
// text edge ~8 px left of the card norm on md+. Body prose in the
// regular foreground; muted stays reserved for meta lines.
return (
<div
data-slot="coach-read-strip"
className="bg-card/60 border-border/60 space-y-2 rounded-xl border px-4 py-3.5"
>
<TileHeader icon={Sparkles} title={t("insights.coach.readStrip.label")} />
<div className="text-foreground min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p data-slot="coach-read-driver" className="text-pretty">
{driverLine}
</p>
) : null}
</div>
</div>
<Card data-slot="coach-read-strip" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
icon={Sparkles}
title={t("insights.coach.readStrip.label")}
/>
</CardHeader>
<CardContent>
<div className="min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
{baselineLine}
</p>
) : null}
{driverLine ? (
<p data-slot="coach-read-driver" className="text-pretty">
{driverLine}
</p>
) : null}
</div>
</CardContent>
</Card>
);
}
+17 -2
View File
@@ -22,8 +22,17 @@
import type { ScoreBand } from "./band-tokens";
/** The per-metric hue keys the wellness strip passes to `<ScoreRing>`. */
export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain";
/** The per-metric hue keys the wellness strip passes to `<ScoreRing>`.
* `meds` is the dashboard dose ring: the medication family in Insights
* paints `--primary` everywhere (the compliance Progress bars, the
* compliance trend line, the drug-level and dose-strength curves), so
* the ring rides the same token — constant, never a band gradient.
* Contrast (computed): dark #bd93f9 — 6.78:1 card / 5.90:1 bg; light
* #644ac9 — 6.00:1 card / 5.60:1 bg — far above the 3:1 graphics
* floor in both themes, and a future `--primary` retune carries the
* ring along. */
export type RingHue =
"readiness" | "sleep" | "recovery" | "stress" | "strain" | "meds";
/**
* Near-flat single-hue arc `[from, to]` per metric, plus a band fallback for
@@ -38,6 +47,9 @@ export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
sleep: ["var(--ring-sleep-from)", "var(--ring-sleep-to)"],
stress: ["var(--ring-stress-from)", "var(--ring-stress-to)"],
strain: ["var(--ring-strain-from)", "var(--ring-strain-to)"],
// Perfectly flat by design (both stops on the one token) — the med
// family's `--primary`, not a hand-tuned from/to pair.
meds: ["var(--primary)", "var(--primary)"],
// Band fallback for the anatomy detail view (no per-metric hue passed):
green: ["var(--ring-green-from)", "var(--ring-green-to)"],
yellow: ["var(--ring-yellow-from)", "var(--ring-yellow-to)"],
@@ -54,4 +66,7 @@ export const TILE_HUE: Record<RingHue, string> = {
recovery: "var(--tile-recovery)",
stress: "var(--tile-stress)",
strain: "var(--tile-strain)",
// No wellness tile carries the meds hue; the dose ring lives on the
// dashboard hero only. Primary keeps the record total.
meds: "var(--primary)",
};
@@ -121,7 +121,7 @@ export function DeviceScoreTile({
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-3 pb-1">
<CardContent className="space-y-3">
{isLearning ? (
<LearningGate
compact
@@ -302,7 +302,7 @@ export function HealthScoreCard({
data-slot="health-score-card"
data-band={band}
className={cn(
"bg-card/65 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
"bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6",
BAND_BORDER_CLASS[band],
// v1.4.27 MB7 / CF-34 — basis-based width so the score column
// flexes inside the hero strip's `md:flex-row` split. Earlier
+2 -2
View File
@@ -285,7 +285,7 @@ export function HeroStrip({
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1
@@ -368,7 +368,7 @@ export function HeroStrip({
data-slot="health-score-card-skeleton"
aria-hidden="true"
className={cn(
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
"bg-card/65 border-border/60 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6",
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
"flex h-full min-h-64 flex-col gap-3.5",
)}
+16 -27
View File
@@ -107,7 +107,7 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
className="gap-1.5 py-4 md:py-5"
className="gap-2 py-3 md:py-4"
>
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
@@ -140,11 +140,10 @@ export function InsightStatusCard({
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-loading"
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
// rhythm the populated / preparing / empty states use, so the
// heading and the first skeleton line sit on the same tight
// baseline across every assessment-card state.
className="gap-1.5 py-4 md:py-5"
// Compact card density — the same tier the populated / preparing /
// empty states use, so the heading and the first skeleton line sit
// on the same baseline across every assessment-card state.
className="gap-2 py-3 md:py-4"
>
<CardHeader>
<div className="flex items-center gap-2">
@@ -165,7 +164,7 @@ export function InsightStatusCard({
if (!hasProvider) {
return (
<Card className="gap-1.5 py-4 opacity-80 md:py-5">
<Card className="gap-2 py-3 opacity-80 md:py-4">
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
@@ -187,7 +186,7 @@ export function InsightStatusCard({
if (!text) {
return (
<Card className="gap-1.5 py-4 md:py-5">
<Card className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
@@ -211,25 +210,15 @@ export function InsightStatusCard({
// v1.12.2 — stable hook for the spine guard test, which asserts the
// assessment is the LAST content block on every bespoke metric page.
data-slot="insight-assessment"
// v1.8.5 W4a — the `Card` primitive ships `gap-4 md:gap-6` as the flex
// gap between header and content, which floated the "Einschätzung"
// heading ~16-24 px above its prose. Override to `gap-2 md:gap-3` (the
// `CardHeader`'s own `pb-2` does not touch the flex gap) and trim the
// shell padding to `py-4 md:py-5` so the assessment block reads tight
// in the denser sub-page rhythm. The same override lands on every
// status variant below so the gap is consistent across states.
//
// v1.8.6 — drop the purple left accent (`border-l-dracula-purple
// border-l-2`). The coloured rule made the assessment card read as
// restless against the calmer surrounding cards; the plain card
// border carries enough separation on its own.
//
// v1.12.8 — tighten the header-to-prose gap. The card flex gap is
// `gap-1.5` (was `gap-2 md:gap-3`) and the `CardHeader` trims its
// bottom padding to `pb-1` (was `pb-2`), so the `<TileHeader>` sits
// close to the prose and matches the header-to-body rhythm of the
// sibling tiles. The loading skeleton keeps its own spacing.
className="animate-insight-in gap-1.5 py-4 md:py-5"
// Compact card density (`gap-2 py-3 md:py-4`) — the sanctioned tight
// tier, so the assessment shares one header→body rhythm with the
// target summary, the read strip, and the stat strip on the same
// spine. Earlier bespoke values (`gap-1.5` + `md:py-5`) predate the
// two-density contract and were normalised onto it. The same class
// lands on every status variant so the gap is consistent across
// states. (The one-time purple left accent stays dropped — the plain
// card border carries enough separation.)
className="animate-insight-in gap-2 py-3 md:py-4"
>
<CardHeader>
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
+3 -3
View File
@@ -252,8 +252,8 @@ export function LayoutCoachFab() {
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.
"from-dracula-purple to-dracula-pink text-background bg-gradient-to-br",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"from-primary to-brand-pink text-background bg-gradient-to-br",
"hover:from-primary/90 hover:to-brand-pink/90",
// The default ring alone is hard to see against the gradient;
// the offset ring draws a clear halo around the circle.
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
@@ -285,7 +285,7 @@ export function LayoutCoachFab() {
<span
data-slot="coach-fab-unread"
aria-hidden="true"
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
className="border-background bg-info absolute top-0.5 right-0.5 size-3 rounded-full border-2"
/>
) : null}
</Button>
@@ -102,7 +102,7 @@ export function MeasurementDiversityNudge({
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-warning hover:text-warning/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
@@ -296,14 +296,14 @@ function TargetReferencePanel({
target.consistency7d.length > 0;
return (
// Card-wrapped so the header-to-body offset matches the `<CardHeader
// pb-2>` tiles on the same subpage spine rather than the old bare-div
// `space-y-3`. The body keeps its inter-row `space-y-3` inside
// Card-wrapped at the compact density so the header-to-body offset
// matches the assessment / read-strip / stat-strip tiles on the same
// subpage spine. The body keeps its inter-row `space-y-3` inside
// `CardContent`; every data-slot is preserved.
<Card
data-slot="metric-target-summary"
data-target-type={target.type}
className="gap-2 py-4 md:gap-3 md:py-5"
className="gap-2 py-3 md:py-4"
>
{/* Row 1: the canonical tile header — a single-word "Ziel" / "Target"
heading (the range numbers live on the range bar below, so the
@@ -287,7 +287,7 @@ export function MoodInsightsSections({
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
// rhythm the other assessment cards use, so the heading sits tight
// above its body instead of floating ~16-24 px above it.
className="animate-insight-in gap-1.5 py-4 md:py-5"
className="animate-insight-in gap-2 py-3 md:py-4"
>
<CardHeader>
<TileHeader
@@ -118,8 +118,8 @@ export function PersonalRecordBadge({
// `--success` resolves to the AA-tuned green per theme: a deep
// forest green on the light card (#14720a) and Dracula's neon
// green on the dark card (#50fa7b). Both clear 4.5:1 against
// their card background — the previous `text-dracula-green` on
// `bg-dracula-green/10` measured ~2.6:1 in dark mode.
// their card background — the previous `text-success` on
// `bg-success/10` measured ~2.6:1 in dark mode.
"border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase tabular-nums",
className,
)}
@@ -62,10 +62,10 @@ export function sortRecommendationsBySeverity(
}
const SEVERITY_BORDER_CLASSES: Record<string, string> = {
urgent: "border-l-dracula-red/70",
important: "border-l-dracula-orange/70",
suggestion: "border-l-dracula-purple/70",
info: "border-l-dracula-cyan/70",
urgent: "border-l-destructive/70",
important: "border-l-warning/70",
suggestion: "border-l-primary/70",
info: "border-l-info/70",
};
const SEVERITY_BORDER_FALLBACK = "border-l-border";
@@ -84,9 +84,7 @@ export function RecoveryMetricBlock({
with no skipped level. */}
<h2 className="text-base font-semibold">{title}</h2>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
{explainer}
</p>
<p className="text-foreground text-sm leading-relaxed">{explainer}</p>
</div>
<MetricStatStrip
@@ -113,7 +113,7 @@ export function ResilienceTile() {
) : null}
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{t("insights.resilience.explainer")}
</p>
{count < MIN_TREND_READINGS ? (
+1 -1
View File
@@ -247,7 +247,7 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to
// `gap-3` so the header sits closer to the timeline, reclaiming the empty
// band the maintainer flagged. The chart keeps its 200 px footprint.
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<Card data-slot="sleep-hypnogram" className="gap-2">
<CardHeader>
<div className="flex flex-col gap-0.5">
<TileHeader title={t("insights.sleep.hypnogram.title")} />
@@ -91,11 +91,11 @@ export function SuggestedPrompts({
// target clears the 44 px iOS/WCAG floor on phones (`min-h-11`)
// and relaxes to the denser 36 px from `sm` up.
className={cn(
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"border-primary/18 hover:border-primary/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
"focus-visible:ring-primary/50",
)}
>
<Quote className="h-3 w-3 shrink-0" aria-hidden="true" />
+1 -1
View File
@@ -86,7 +86,7 @@ export function TrendCaptionCard({
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<Sparkles
className="text-dracula-purple mt-0.5 h-3.5 w-3.5 shrink-0"
className="text-primary mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
+2 -4
View File
@@ -245,7 +245,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? t("labs.detail.title")}
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
@@ -340,9 +340,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
heading, mirroring the metric sub-pages' explainer caption. Computed
once above (catalog desc → user `context` → generic fallback) so a
catalog-less marker still carries a description. */}
<p className="text-muted-foreground text-sm leading-relaxed">
{description}
</p>
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{isLoading ? (
<Card>
@@ -70,15 +70,15 @@ describe("MeasurementList — APPLE_HEALTH badge", () => {
expect(html).not.toContain(">APPLE_HEALTH<");
});
it("paints the badge with the Dracula-pink utility classes", () => {
it("paints the badge with the brand-pink utility classes", () => {
const html = render("en");
// The chip class composition (`border-dracula-pink/50`,
// `bg-dracula-pink/15`, `text-dracula-pink`) is what gives the
// The chip class composition (`border-brand-pink/50`,
// `bg-brand-pink/15`, `text-brand-pink`) is what gives the
// badge its iOS-accent identity. Asserting all three keeps the
// colour contract from drifting silently.
expect(html).toMatch(/border-dracula-pink\/50/);
expect(html).toMatch(/bg-dracula-pink\/15/);
expect(html).toMatch(/text-dracula-pink/);
expect(html).toMatch(/border-brand-pink\/50/);
expect(html).toMatch(/bg-brand-pink\/15/);
expect(html).toMatch(/text-brand-pink/);
});
it("renders the localised label in German too", () => {
@@ -274,7 +274,7 @@ function dayBoundaryIso(
*/
function sourceBadgeClass(source: string): string {
if (source === "APPLE_HEALTH") {
return "border-dracula-pink/50 bg-dracula-pink/15 text-dracula-pink";
return "border-brand-pink/50 bg-brand-pink/15 text-brand-pink";
}
return "";
}
@@ -1145,7 +1145,7 @@ export function MeasurementList({
<ListRow
key={m.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
@@ -87,7 +87,7 @@ function ComplianceMetaRow({
* The streak flame uses the semantic `text-warning` token (an alias over
* Dracula orange in dark mode, AA-safe on the light card). The generic card
* historically drifted onto Tailwind stock `text-orange-400`, and the flame
* later carried the raw `text-dracula-orange` palette token; v1.12.2 routes
* later carried the raw `text-warning` palette token; v1.12.2 routes
* it through the semantic vocabulary the rest of the status surface uses.
*/
export function MedicationComplianceBars({
@@ -238,7 +238,7 @@ export function IntakeHistoryListV2({
{t("medications.intakeHistory")}
</CardTitle>
</CardHeader>
<CardContent className="pb-4 md:pb-6">
<CardContent>
{isLoading ? (
<div className="flex h-24 items-center justify-center">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
@@ -125,7 +125,7 @@ export function ResearchModeAcknowledgmentDialog({
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
<BookOpenCheck className="text-primary h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</span>
}
@@ -171,7 +171,7 @@ export function ScheduleHistoryTimeline({
<ol className="space-y-0">
{/* Live plan — the accent node. */}
<TimelineRow
dotClassName="bg-dracula-green"
dotClassName="bg-success"
railVisible={expanded && revisions.length > 0}
dataSlot="zeitplan-history-current"
>
@@ -208,7 +208,7 @@ export function ScheduleHistoryTimeline({
: t("medications.detail.zeitplan.history.noTimes")}
{revision.source === "MANUAL" && (
<span
className="border-dracula-purple/30 bg-dracula-purple/10 text-dracula-purple ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
className="border-primary/30 bg-primary/10 text-primary ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
data-slot="zeitplan-history-manual-chip"
>
{t("medications.detail.zeitplan.history.manualChip")}
@@ -240,7 +240,7 @@ export function ScheduleHistoryTimeline({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-dracula-red size-8"
className="text-muted-foreground hover:text-destructive size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
)}
@@ -569,7 +569,7 @@ function EraDialog({
</p>
{validationKey !== null && (
<p
className="text-dracula-red text-sm"
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
+2 -2
View File
@@ -688,7 +688,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
key={entry.id}
data-testid="mood-row"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
@@ -1098,7 +1098,7 @@ function MoodNoteText({
aria-expanded={expanded}
aria-controls={noteId}
data-testid="mood-note-toggle"
className="text-dracula-purple cursor-pointer text-[11px] font-medium"
className="text-primary cursor-pointer text-[11px] font-medium"
>
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</button>
+2 -2
View File
@@ -74,14 +74,14 @@ export function DoneScreen() {
<div className="border-border/60 bg-muted/30 mx-auto flex w-full max-w-md flex-col items-center gap-2 rounded-lg border p-4 text-center">
<span
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-9 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink flex size-9 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-4" />
</span>
<p className="text-foreground text-sm font-medium">
{t("onboarding.done.aiTitle")}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{t("onboarding.done.aiBody")}
</p>
<Link
@@ -211,7 +211,7 @@ export function WelcomeCarousel() {
>
{t(slide.titleKey)}
</h2>
<p className="text-muted-foreground text-sm leading-relaxed">
<p className="text-foreground text-sm leading-relaxed">
{t(slide.bodyKey)}
</p>
</article>
@@ -302,7 +302,7 @@ export function WelcomeCarousel() {
/>
<label
htmlFor={disclaimerId}
className="text-muted-foreground text-sm leading-relaxed"
className="text-foreground text-sm leading-relaxed"
>
{t("onboarding.disclaimer.acknowledge")}{" "}
<Link
+5 -5
View File
@@ -192,7 +192,7 @@ export function AboutMeSection({
aria-hidden="true"
className={cn(
"h-1 w-5 rounded-full transition-colors",
i < answered ? "bg-dracula-purple" : "bg-border",
i < answered ? "bg-primary" : "bg-border",
)}
/>
))}
@@ -272,11 +272,11 @@ export function AboutMeSection({
{pendingQuestions.length > 0 && (
<div
data-testid="settings-about-me-questions"
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-dracula-purple size-4 shrink-0"
className="text-primary size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
@@ -289,7 +289,7 @@ export function AboutMeSection({
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-dracula-purple underline-offset-4 hover:underline"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
@@ -318,7 +318,7 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
className="text-dracula-purple underline-offset-4 hover:underline"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.recordsLinkCta")}
</Link>
@@ -67,7 +67,7 @@ export function CoachRemindersSection({
data-status={r.status}
className={
highlighted
? "border-dracula-purple/40 bg-dracula-purple/5 flex flex-col gap-2 rounded-lg border p-3"
? "border-primary/40 bg-primary/5 flex flex-col gap-2 rounded-lg border p-3"
: "border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
}
>
@@ -73,7 +73,7 @@ export function InjectionSitesCard({
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-3 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
<div className="mt-4 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
+5 -6
View File
@@ -121,16 +121,15 @@ export function ModulesSection() {
// was removed: a domain that can't be turned off doesn't need to be listed.
return (
<SettingsCard>
{/* The "what this section does" hint rides the header's standard
description slot; the body follows at the settings-wide mt-4
header→content rhythm instead of the former mb-2/mb-3 one-offs. */}
<SettingsCardHeader
icon={Blocks}
title={t("settings.sections.modules.toggleable.title")}
className="mb-2"
description={t("settings.sections.modules.toggleable.description")}
/>
{/* v1.18.6 — restore the one-line "what this section does" hint. */}
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
{t("settings.sections.modules.toggleable.description")}
</p>
<div className="divide-border divide-y pl-7">
<div className="divide-border mt-4 divide-y pl-7">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
// Modules switched off in code (pending a rebuild) carry no live
// toggle — drop the row entirely so a user can't turn one on.
@@ -20,7 +20,7 @@ function render(props: Parameters<typeof RangeBar>[0]) {
}
describe("<RangeBar>", () => {
it("renders the bar slot + green/orange/red zones with Dracula tokens", () => {
it("renders the bar slot + green/orange/red zones with semantic tokens", () => {
const html = render({
value: 72,
min: 60,
@@ -28,9 +28,9 @@ describe("<RangeBar>", () => {
unit: "bpm",
});
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain("bg-dracula-green/20");
expect(html).toContain("bg-dracula-orange/15");
expect(html).toContain("bg-dracula-red/10");
expect(html).toContain("bg-success/20");
expect(html).toContain("bg-warning/15");
expect(html).toContain("bg-destructive/10");
// Pin the absence of the legacy raw Tailwind palette so the swap
// doesn't silently regress on future merges.
expect(html).not.toContain("bg-green-500/20");
@@ -38,23 +38,23 @@ describe("<RangeBar>", () => {
expect(html).not.toContain("bg-red-500/8");
});
it("paints the marker with the green token when the value is in range", () => {
it("paints the marker with the success token when the value is in range", () => {
const html = render({
value: 80,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain("var(--dracula-green)");
expect(html).toContain("var(--success)");
});
it("paints the marker with the red token when the value is out of band", () => {
it("paints the marker with the destructive token when the value is out of band", () => {
const html = render({
value: 180,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--destructive)");
});
});
+3 -3
View File
@@ -54,9 +54,9 @@ export interface ConsistencyStripProps {
// encoded inside the value via color-mix so the opacity modifier is
// no longer needed and the bracketed form parses cleanly.
const BAND_STYLES: Record<NonNullable<ConsistencyBand>, string> = {
in: "bg-(--dracula-green) border-(--dracula-green) shadow-[0_0_0_1px_color-mix(in_oklab,var(--dracula-green)_20%,transparent)]",
near: "bg-(--dracula-orange) border-(--dracula-orange)",
out: "bg-(--dracula-red) border-(--dracula-red)",
in: "bg-success border-success shadow-[0_0_0_1px_color-mix(in_oklab,var(--success)_20%,transparent)]",
near: "bg-warning border-warning",
out: "bg-destructive border-destructive",
};
const BAND_ARIA: Record<NonNullable<ConsistencyBand>, string> = {
+7 -7
View File
@@ -76,10 +76,10 @@ export function RangeBar({
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
const markerColor = inGreen
? "var(--dracula-green)"
? "var(--success)"
: inYellow
? "var(--dracula-orange)"
: "var(--dracula-red)";
? "var(--warning)"
: "var(--destructive)";
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
@@ -99,18 +99,18 @@ export function RangeBar({
chart palette stays aligned with PR-badge, alerts, and the
marker dot itself. Raw Tailwind palettes drift from the
theme over time and never get dark-mode tuned. */}
<div className="bg-dracula-red/10 absolute inset-0 rounded-full" />
<div className="bg-destructive/10 absolute inset-0 rounded-full" />
{/* Orange (caution) side zones — `--dracula-orange` matches the
marker's out-of-band tint. */}
<div
className="bg-dracula-orange/15 absolute top-0 h-full"
className="bg-warning/15 absolute top-0 h-full"
style={{
left: `${yellowLeftStart}%`,
width: `${greenStart - yellowLeftStart}%`,
}}
/>
<div
className="bg-dracula-orange/15 absolute top-0 h-full"
className="bg-warning/15 absolute top-0 h-full"
style={{
left: `${greenEnd}%`,
width: `${yellowRightEnd - greenEnd}%`,
@@ -118,7 +118,7 @@ export function RangeBar({
/>
{/* In-band green zone — `--dracula-green`. */}
<div
className="bg-dracula-green/20 absolute top-0 h-full"
className="bg-success/20 absolute top-0 h-full"
style={{
left: `${greenStart}%`,
width: `${greenEnd - greenStart}%`,
+1 -1
View File
@@ -26,7 +26,7 @@ function Checkbox({
data-slot="checkbox"
className={cn(
"peer border-border/70 size-4 shrink-0 rounded border",
"data-[state=checked]:bg-dracula-purple data-[state=checked]:border-dracula-purple data-[state=checked]:text-background",
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",
+46
View File
@@ -5,6 +5,8 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
import { getEvent } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isP2025 } from "@/lib/prisma-errors";
import { locales, type Locale } from "@/lib/i18n/config";
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
const SESSION_COOKIE = "healthlog_session";
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@@ -170,12 +172,56 @@ export async function getSession(): Promise<{
.catch(() => {});
}
// Locale-cookie refresh: Safari's ITP expires the script-written
// `healthlog-locale` cookie after 7 days, so a returning user's first
// paint fell back to the browser language once a week. When the user
// has a persisted locale but the cookie is gone, re-emit it here —
// Set-Cookie is exempt from the ITP cap. Absent-only on purpose: a
// locale switch writes the cookie client-side before the PUT persists
// the column, and overwriting a *differing* cookie here would revert
// that fresh choice mid-flight. Fail-soft: in a server-component
// render the cookie store is read-only and `set` throws — the next
// route-handler request re-attempts.
if (
session.user.locale &&
(locales as readonly string[]).includes(session.user.locale) &&
!cookieStore.get(LOCALE_COOKIE)
) {
try {
setLocaleCookie(cookieStore, session.user.locale as Locale);
} catch {
// Read-only cookie context — skip; nothing depends on this write.
}
}
return {
session: { id: session.id, expiresAt: session.expiresAt },
user: session.user,
};
}
/**
* Lightweight read of the signed-in user's persisted locale for the root
* layout's first-paint language resolution. Deliberately NOT
* `getSession()`: no db-compat check, no sliding-expiry write, no cookie
* mutation — the root layout renders on every request in a context where
* the cookie store is read-only, so the full session touch is both dead
* weight and a throw hazard there. Returns null for missing/expired
* sessions and users without a persisted locale.
*/
export async function getSessionUserLocale(): Promise<string | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
if (!sessionId) return null;
const session = await prisma.session.findUnique({
where: { id: sessionId },
select: { expiresAt: true, user: { select: { locale: true } } },
});
if (!session || session.expiresAt < new Date()) return null;
return session.user.locale;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
@@ -0,0 +1,90 @@
/**
* First-paint locale ladder (root layout): cookie → User.locale →
* Accept-Language → "en" hard floor.
*
* The User.locale step is the Safari-ITP fix: the script-written
* `healthlog-locale` cookie expires after 7 days there, and before this
* ladder existed the first paint fell back to the browser language once
* a week even though the user had explicitly chosen one.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCookies = vi.fn();
const mockHeaders = vi.fn();
vi.mock("next/headers", () => ({
cookies: (...args: unknown[]) => mockCookies(...args),
headers: (...args: unknown[]) => mockHeaders(...args),
}));
const mockGetSessionUserLocale = vi.fn();
vi.mock("@/lib/auth/session", () => ({
getSessionUserLocale: (...args: unknown[]) =>
mockGetSessionUserLocale(...args),
}));
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
function cookieStoreWith(value: string | undefined) {
return {
get: (name: string) =>
name === "healthlog-locale" && value !== undefined
? { name, value }
: undefined,
};
}
function headersWith(acceptLanguage: string | null) {
return {
get: (name: string) =>
name.toLowerCase() === "accept-language" ? acceptLanguage : null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockCookies.mockResolvedValue(cookieStoreWith(undefined));
mockHeaders.mockResolvedValue(headersWith(null));
mockGetSessionUserLocale.mockResolvedValue(null);
});
describe("resolveInitialLocale", () => {
it("returns the cookie locale without consulting the session", async () => {
mockCookies.mockResolvedValue(cookieStoreWith("de"));
await expect(resolveInitialLocale()).resolves.toBe("de");
expect(mockGetSessionUserLocale).not.toHaveBeenCalled();
});
it("ignores an unsupported cookie value and walks the ladder", async () => {
mockCookies.mockResolvedValue(cookieStoreWith("tlh"));
mockGetSessionUserLocale.mockResolvedValue("fr");
await expect(resolveInitialLocale()).resolves.toBe("fr");
});
it("falls back to User.locale on a cookie miss (the ITP case)", async () => {
mockGetSessionUserLocale.mockResolvedValue("pl");
mockHeaders.mockResolvedValue(headersWith("en-US,en;q=0.9"));
await expect(resolveInitialLocale()).resolves.toBe("pl");
});
it("ignores an unsupported User.locale value", async () => {
mockGetSessionUserLocale.mockResolvedValue("tlh");
mockHeaders.mockResolvedValue(headersWith("it-IT,it;q=0.9"));
await expect(resolveInitialLocale()).resolves.toBe("it");
});
it("uses Accept-Language when there is no cookie and no session", async () => {
mockHeaders.mockResolvedValue(headersWith("es-ES,es;q=0.9,en;q=0.5"));
await expect(resolveInitialLocale()).resolves.toBe("es");
});
it("survives a session-read failure and still honours Accept-Language", async () => {
mockGetSessionUserLocale.mockRejectedValue(new Error("db down"));
mockHeaders.mockResolvedValue(headersWith("de-DE,de;q=0.9"));
await expect(resolveInitialLocale()).resolves.toBe("de");
});
it("hard-floors to en when cookies() itself throws", async () => {
mockCookies.mockRejectedValue(new Error("DynamicServerError"));
await expect(resolveInitialLocale()).resolves.toBe("en");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { cookies } from "next/headers";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import type { Locale } from "@/lib/i18n/config";
/**
* The SSR handoff cookie for the active UI locale. The client-side
* switcher writes it via `document.cookie` (src/lib/i18n/context.tsx),
* but Safari's ITP caps script-written cookies at 7 days — a user who
* doesn't open the app for a week silently falls back to the browser
* language on first paint. Server-side `Set-Cookie` writes are exempt
* from that cap, so every server seam that knows the persisted locale
* re-emits the cookie through this one helper.
*/
export const LOCALE_COOKIE = "healthlog-locale";
// One year — mirrors the client-side `persistLocale` write.
const LOCALE_COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365;
type CookieStore = Awaited<ReturnType<typeof cookies>>;
/**
* Emit the locale cookie server-side. Throws when the cookie store is
* read-only (server-component render) — callers on that path catch and
* skip; the next route-handler request re-attempts.
*/
export function setLocaleCookie(cookieStore: CookieStore, locale: Locale) {
cookieStore.set(LOCALE_COOKIE, locale, {
// NOT HttpOnly — the client reads it (format helpers, hydration
// handoff in src/lib/format.ts and i18n/context.tsx).
httpOnly: false,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: LOCALE_COOKIE_MAX_AGE_S,
path: "/",
});
}
+47
View File
@@ -0,0 +1,47 @@
import { headers, cookies } from "next/headers";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { LOCALE_COOKIE } from "@/lib/i18n/locale-cookie";
import { getSessionUserLocale } from "@/lib/auth/session";
/**
* First-paint locale ladder for the root layout:
*
* 1. `healthlog-locale` cookie — the SSR handoff the client writes on
* every switch and the server re-emits on session touch.
* 2. `User.locale` — the persisted preference. Consulted only on a
* cookie miss (Safari ITP expires the script-written cookie after
* 7 days; without this step the app fell back to the browser
* language once a week even though the user had chosen one).
* 3. Accept-Language — users who never chose stay on the browser
* language ("automatic").
*
* Fail-soft throughout: cookies()/headers() can throw
* (DynamicServerError, …) and the session read can hit a DB hiccup — a
* locale resolution problem must never crash the root layout into
* global-error.tsx.
*/
export async function resolveInitialLocale(): Promise<Locale> {
try {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
return cookieLocale as Locale;
}
let userLocale: string | null = null;
try {
userLocale = await getSessionUserLocale();
} catch {
// DB hiccup — fall through to Accept-Language rather than "en".
}
if (userLocale && (locales as readonly string[]).includes(userLocale)) {
return userLocale as Locale;
}
const headerList = await headers();
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
} catch {
return "en";
}
}