mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #420 from MBombeck/release/v1.27.11
Release v1.27.11 — consistency, colour, and locale fixes
This commit is contained in:
94 files changed
+968
-346
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -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
@@ -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%");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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: "/",
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user