From e9692f0b818be73d5d50983be5df57fb14a2fa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 21:01:50 +0200 Subject: [PATCH 1/8] fix(i18n): survive Safari's 7-day cookie cap on the chosen locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-paint ladder consulted only the healthlog-locale cookie and Accept-Language — never User.locale. The cookie is written client-side via document.cookie, which Safari ITP caps at 7 days, so a chosen language silently reverted to the browser language once a week. Three seams close it: - resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts) now reads User.locale on a cookie miss via a lightweight session read (no sliding-expiry write, no cookie mutation, fail-soft) before the Accept-Language fallback. - PUT /api/auth/me/locale mirrors the accepted locale into the cookie via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via shouldEmitSecureCookie) — server-set cookies are exempt from the ITP cap, and the mount-time backfill hitting the route doubles as the weekly refresh. - getSession re-emits the cookie when the user has a persisted locale but the cookie is absent (absent-only, so a mid-switch cookie is never reverted before the PUT lands). Users who never chose a language stay on Accept-Language. --- src/app/api/auth/me/locale/route.ts | 12 +++ src/app/layout.tsx | 22 +---- src/lib/auth/session.ts | 46 ++++++++++ .../__tests__/resolve-initial-locale.test.ts | 90 +++++++++++++++++++ src/lib/i18n/locale-cookie.ts | 36 ++++++++ src/lib/i18n/resolve-initial-locale.ts | 47 ++++++++++ 6 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 src/lib/i18n/__tests__/resolve-initial-locale.test.ts create mode 100644 src/lib/i18n/locale-cookie.ts create mode 100644 src/lib/i18n/resolve-initial-locale.ts 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/layout.tsx b/src/app/layout.tsx index 1322ebb68..0643c3cba 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { - // 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"], diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4e82e949a..bcddcc777 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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 { + 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 { const cookieStore = await cookies(); const sessionId = cookieStore.get(SESSION_COOKIE)?.value; diff --git a/src/lib/i18n/__tests__/resolve-initial-locale.test.ts b/src/lib/i18n/__tests__/resolve-initial-locale.test.ts new file mode 100644 index 000000000..91cd2cc50 --- /dev/null +++ b/src/lib/i18n/__tests__/resolve-initial-locale.test.ts @@ -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"); + }); +}); diff --git a/src/lib/i18n/locale-cookie.ts b/src/lib/i18n/locale-cookie.ts new file mode 100644 index 000000000..3925bc666 --- /dev/null +++ b/src/lib/i18n/locale-cookie.ts @@ -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>; + +/** + * 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: "/", + }); +} diff --git a/src/lib/i18n/resolve-initial-locale.ts b/src/lib/i18n/resolve-initial-locale.ts new file mode 100644 index 000000000..5407735db --- /dev/null +++ b/src/lib/i18n/resolve-initial-locale.ts @@ -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 { + 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"; + } +} From 68b4dd08a70ebba0370f5c763269756fc32b33fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 21:10:01 +0200 Subject: [PATCH 2/8] feat(lint): guard the gap-based Card slots against padding overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add healthlog/spacing-scale (error): pt-*/pb-* on CardHeader/CardContent 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. This is the drift class the earlier slot-padding sweep cleaned; the rule keeps it from reaccumulating. Density belongs on the Card itself (gap-2 py-3 md:py-4). No allowlist — an exception, should one ever be load-bearing, carries an inline disable with its reason. Remove the two stragglers the rule caught: pb-1 on the device-score tile and pb-4 md:pb-6 on the intake history list, both stacking dead padding on top of the card gap. RuleTester coverage for the modifier-prefixed, arbitrary-value, cn()/ternary/template and member-expression forms. --- .../__tests__/spacing-scale.test.mjs | 112 ++++++++++++ eslint-plugins/healthlog/index.js | 3 + eslint-plugins/healthlog/spacing-scale.js | 169 ++++++++++++++++++ eslint.config.mjs | 4 + src/components/insights/device-score-tile.tsx | 2 +- .../medications/intake-history-list-v2.tsx | 2 +- 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs create mode 100644 eslint-plugins/healthlog/spacing-scale.js 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..fe97d8368 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,6 +23,7 @@ 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 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..2ef764ed1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -64,6 +64,10 @@ const eslintConfig = defineConfig([ // 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 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/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 40a10fd9c..178617bb1 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -121,7 +121,7 @@ export function DeviceScoreTile({ ) : null} - + {isLearning ? ( - + {isLoading ? (
From a4b204ffe405f526d6b7bc59d9491a7392f8dd88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 21:28:09 +0200 Subject: [PATCH 3/8] refactor(ui): retire the raw dracula utilities for semantic tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate every remaining *-dracula-* utility site (127 lint warnings, 193 class occurrences) onto the semantic vocabulary. The swaps are pixel-identical in both themes — each target token aliases the same primitive the utility read, including the light-theme AA overrides: - green/orange/cyan/red → success/warning/info/destructive - purple → primary (same value in both themes) - pink → the new --brand-pink token (Coach gradient, Apple-Health badge, recording mic): dark #ff79c6 — 6.86:1 card / 5.97:1 bg; light #a3134d — 7.32:1 card / 6.83:1 bg (computed) - yellow sites resolve to their status meaning: warn-level log icon, update-available pill and offline-geo hint → warning; the admin helpful-rate ramp → success/warning/destructive; the confidence meter mirrors the coverage-meter precedent (medium + low share the caution tint, the lit count carries the distinction) - the confidence-badge low tier dropped dead *-dracula-comment/* classes (no generated utility) for border-border + bg-muted/50 Close two rule blind spots the sweep surfaced — directional borders (border-l-dracula-red) and the parenthesised CSS-var shorthand (bg-(--dracula-green)) — and flip healthlog/no-dracula-utility from warn to error so the class cannot regrow. var(--dracula-*) references in chart code stay: they are token references the light theme overrides, and the chart palette is pinned visually. --- .../__tests__/no-raw-palette-color.test.mjs | 33 +++++++++++++++++ eslint-plugins/healthlog/index.js | 6 ++-- .../healthlog/no-raw-palette-color.js | 22 ++++++------ eslint.config.mjs | 12 ++++--- src/app/coach/conversations/page.tsx | 2 +- src/app/globals.css | 35 +++++++++++++++---- .../__tests__/coach-feedback-section.test.tsx | 6 ++-- src/components/admin/_shared.tsx | 13 ++++--- src/components/admin/ai-quality-section.tsx | 8 ++--- .../admin/api-token-overview-section.tsx | 4 +-- .../admin/app-log-preview-section.tsx | 4 +-- src/components/admin/danger-zone-section.tsx | 2 +- .../admin/invite-tokens-section.tsx | 22 ++++++------ .../admin/login-overview-section.tsx | 4 +-- src/components/admin/recent-audit-preview.tsx | 2 +- .../admin/system-status-section.tsx | 16 ++++----- .../admin/system-status-summary.tsx | 8 ++--- .../admin/user-management-section.tsx | 4 +-- src/components/admin/version-tile-section.tsx | 4 +-- .../charts/medication-compliance-chart.tsx | 4 +-- .../__tests__/confidence-meter.test.tsx | 14 ++++---- .../__tests__/recommendations-grid.test.tsx | 10 +++--- .../__tests__/suggested-prompts.test.tsx | 4 +-- .../coach-panel/coach-conversation.tsx | 2 +- .../insights/coach-panel/coach-hero.tsx | 4 +-- .../insights/coach-panel/coach-input.tsx | 6 ++-- .../coach-panel/guided-dialog-bubbles.tsx | 19 +++++----- .../coach-panel/guided-questions-card.tsx | 12 +++---- .../insights/coach-panel/history-rail.tsx | 4 +-- .../insights/coach-panel/message-thread.tsx | 16 ++++----- .../coach-panel/reminder-suggestion-card.tsx | 6 ++-- .../insights/coach-panel/scope-hint-badge.tsx | 5 +-- .../coach-panel/self-context-adopt-offer.tsx | 12 +++---- .../insights/coach-panel/source-chips.tsx | 2 +- .../insights/coach-panel/sources-rail.tsx | 4 +-- .../coach-panel/suggested-action-card.tsx | 6 ++-- src/components/insights/confidence-badge.ts | 6 +++- src/components/insights/confidence-meter.tsx | 18 ++++++---- src/components/insights/correlation-card.tsx | 13 +++---- src/components/insights/hero-strip.tsx | 2 +- src/components/insights/layout-coach-fab.tsx | 6 ++-- .../insights/measurement-diversity-nudge.tsx | 2 +- .../insights/personal-record-badge.tsx | 4 +-- .../insights/recommendations-grid.tsx | 8 ++--- src/components/insights/suggested-prompts.tsx | 4 +-- src/components/insights/trend-annotation.tsx | 2 +- ...asurement-list-apple-health-badge.test.tsx | 12 +++---- .../measurements/measurement-list.tsx | 4 +-- .../card-parts/medication-compliance-bars.tsx | 2 +- .../research-mode-acknowledgment-dialog.tsx | 2 +- .../scheduling/schedule-history-timeline.tsx | 8 ++--- src/components/mood/mood-list.tsx | 4 +-- src/components/onboarding/done-screen.tsx | 2 +- src/components/settings/about-me-section.tsx | 10 +++--- .../settings/coach-reminders-section.tsx | 2 +- .../targets/__tests__/range-bar.test.tsx | 16 ++++----- src/components/targets/consistency-strip.tsx | 6 ++-- src/components/targets/range-bar.tsx | 14 ++++---- src/components/ui/checkbox.tsx | 2 +- 59 files changed, 272 insertions(+), 214 deletions(-) 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/index.js b/eslint-plugins/healthlog/index.js index fe97d8368..ca06edf90 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -25,9 +25,9 @@ module.exports = { "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.config.mjs b/eslint.config.mjs index 2ef764ed1..992d0e885 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -59,11 +59,13 @@ 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. 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", )} >