diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad103531..d29107675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8513e4890..750a8da58 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 6f9d7f854..111068f4a 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -203,6 +203,26 @@ export default async function globalSetup(config: FullConfig): Promise { 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( diff --git a/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs index b55629c45..5283c827a 100644 --- a/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs +++ b/eslint-plugins/healthlog/__tests__/no-raw-palette-color.test.mjs @@ -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" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs new file mode 100644 index 000000000..6bb22a55c --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs @@ -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: 'x', + filename: APP_FILE, + }, + // Slots without className, or with unrelated classes, are fine. + { code: "", filename: APP_FILE }, + { + code: '', + filename: APP_FILE, + }, + // Other paddings on slots are not this rule's business. + { + code: '', + filename: APP_FILE, + }, + // Safe-area helpers are not scale steps and never match. + { + code: '', + filename: APP_FILE, + }, + // pt-/pb- on arbitrary elements stays legal — the contract is about + // the gap-based Card slots only. + { code: '
', filename: APP_FILE }, + // Outside the enforced roots nothing matches. + { + code: '', + filename: "/repo/scripts/tool.tsx", + }, + // Test files are exempt. + { + code: '', + filename: "/repo/src/components/__tests__/example.test.tsx", + }, + ], + invalid: [ + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Modifier-prefixed and fractional steps. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Arbitrary-value padding. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Collected through cn() args… + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // …ternaries… + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }, { messageId: "slotPadding" }], + }, + // …and template literals. + { + code: "", + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + // Member-expression component tails match too. + { + code: '', + filename: APP_FILE, + errors: [{ messageId: "slotPadding" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index 57d105ca5..ca06edf90 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -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, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js index 3dfcffc40..4550d7adb 100644 --- a/eslint-plugins/healthlog/no-raw-palette-color.js +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -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 diff --git a/eslint-plugins/healthlog/spacing-scale.js b/eslint-plugins/healthlog/spacing-scale.js new file mode 100644 index 000000000..0cf06c9f8 --- /dev/null +++ b/eslint-plugins/healthlog/spacing-scale.js @@ -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 + * `` or a `pt-*` on `` 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 (``, + * 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-` / `pb-` 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 `
`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: .', + }, + }, + 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; diff --git a/eslint.config.mjs b/eslint.config.mjs index 4776807ab..992d0e885 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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", }, }, ]); diff --git a/package.json b/package.json index d9c828e9f..bd1d8ff2f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 00a96c38a..9383fd92d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts index 06681f44a..2b793be89 100644 --- a/src/app/api/auth/me/locale/route.ts +++ b/src/app/api/auth/me/locale/route.ts @@ -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 }); }); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index c2677c2d7..6110786cb 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -224,7 +224,7 @@ function CoachConversationsBody() { className={cn( "shrink-0", isConfirming && - "text-dracula-red hover:text-dracula-red", + "text-destructive hover:text-destructive", )} >