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",
)}
>
diff --git a/src/app/globals.css b/src/app/globals.css
index 7d80d2e82..7ee458730 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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 */
diff --git a/src/components/admin/__tests__/coach-feedback-section.test.tsx b/src/components/admin/__tests__/coach-feedback-section.test.tsx
index a475ac91f..2dd8500d1 100644
--- a/src/components/admin/__tests__/coach-feedback-section.test.tsx
+++ b/src/components/admin/__tests__/coach-feedback-section.test.tsx
@@ -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%");
diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx
index 52bc60302..bb9b87736 100644
--- a/src/components/admin/_shared.tsx
+++ b/src/components/admin/_shared.tsx
@@ -122,7 +122,7 @@ export interface ApiTokenInfo {
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
-
+
{t("admin.configured")}
);
@@ -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 {
diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx
index 3b0e75ab0..98e31a7a7 100644
--- a/src/components/admin/ai-quality-section.tsx
+++ b/src/components/admin/ai-quality-section.tsx
@@ -46,10 +46,10 @@ interface FeedbackSummary {
}
const SEVERITY_TINT: Record = {
- 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() {
diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx
index aa3af44c2..507a74ad6 100644
--- a/src/components/admin/api-token-overview-section.tsx
+++ b/src/components/admin/api-token-overview-section.tsx
@@ -92,9 +92,7 @@ function TokenStatusBadge({
);
}
return (
-
+
{t("common.active")}
);
diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx
index 995619b62..099fba76f 100644
--- a/src/components/admin/app-log-preview-section.tsx
+++ b/src/components/admin/app-log-preview-section.tsx
@@ -75,12 +75,12 @@ function levelIcon(level: LogLevel) {
case "error":
return ;
case "warn":
- return ;
+ return ;
case "debug":
return ;
case "info":
default:
- return ;
+ return ;
}
}
diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx
index 923099bde..105e33893 100644
--- a/src/components/admin/danger-zone-section.tsx
+++ b/src/components/admin/danger-zone-section.tsx
@@ -171,7 +171,7 @@ export function DangerZoneSection() {
{wipeMsg && (
{wipeMsg}
diff --git a/src/components/admin/invite-tokens-section.tsx b/src/components/admin/invite-tokens-section.tsx
index 3eebf3546..7e911c3be 100644
--- a/src/components/admin/invite-tokens-section.tsx
+++ b/src/components/admin/invite-tokens-section.tsx
@@ -145,20 +145,20 @@ const STATUS_STYLES: Record = {
// 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() {
{/* The ticket — dashed edge marks the one-time nature. */}