refactor(ui): retire the raw dracula utilities for semantic tokens

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 21:28:09 +02:00
1 parent 68b4dd08a7
commit a4b204ffe4
59 files changed
+272 -214

No files matched your search

@@ -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" }],
},
],
});
+3 -3
View File
@@ -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,
},
};
@@ -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
+7 -5
View File
@@ -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.
+1 -1
View File
@@ -224,7 +224,7 @@ function CoachConversationsBody() {
className={cn(
"shrink-0",
isConfirming &&
"text-dracula-red hover:text-dracula-red",
"text-destructive hover:text-destructive",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
+28 -7
View File
@@ -104,6 +104,17 @@
--color-warning: var(--warning);
--color-info: var(--info);
/*
* Brand accent tokens — identity colours with no semantic (status)
* twin. `--brand-pink` is the Coach-gradient / Apple-Health accent
* (`from-primary to-brand-pink`, the iOS-source badge, the recording
* mic). Per-theme values ride the `--dracula-pink` primitive, which
* the light theme overrides to its AA-tuned counterpart:
* dark #ff79c6 — 6.86:1 card / 5.97:1 bg; light #a3134d — 7.32:1
* card / 6.83:1 bg (WCAG relative-luminance, computed).
*/
--color-brand-pink: var(--brand-pink);
/*
* Status-pill text tokens. The target status pill paints coloured text
* on a 10% same-hue tint; the raw Dracula green/orange/red would fail
@@ -160,6 +171,14 @@
--warning: var(--dracula-orange);
--info: var(--dracula-cyan);
/*
* Brand accent (see the @theme re-export). Aliased over the pink
* primitive so the light theme's AA-tuned `--dracula-pink` override
* carries through automatically — pixel-identical to the raw utility
* sites it replaced.
*/
--brand-pink: var(--dracula-pink);
/*
* Foreground for glyphs/labels painted on a solid `bg-success` fill. The
* raw Dracula green (`#50fa7b`) is a bright tone, so white-on-green reads
@@ -337,13 +356,15 @@
--chart-5: #a34d14;
/*
* Light-mode (Alucard) overrides for the raw `--dracula-*` primitives.
* ~250 legacy `text-dracula-*` / opacity-suffixed `bg-dracula-*` call
* sites still read these directly; without an override the dark neons render on the white
* card at 1.2–1.6:1. Each value below is tuned to the AA light palette
* already in this block (same hue family, measured >=4.5:1 on both
* `--card` oklch(0.988 0.003 300) and `--background` oklch(0.964 0.004
* 300)), so every legacy utility inherits a readable tone until the
* semantic sweep retires it.
* The semantic sweep retired every `text-dracula-*` / `bg-dracula-*`
* UTILITY site (lint-enforced via healthlog/no-dracula-utility), but
* chart code still passes `var(--dracula-*)` token references
* (Recharts stroke/fill maps) and the Coach gradient CSS below reads
* the purple/pink primitives — without an override the dark neons
* render on the white card at 1.2–1.6:1. Each value below is tuned to
* the AA light palette already in this block (same hue family,
* measured >=4.5:1 on both `--card` oklch(0.988 0.003 300) and
* `--background` oklch(0.964 0.004 300)).
*/
--dracula-cyan: #036a96; /* shadows --info — 5.78:1 card / 5.39:1 bg */
--dracula-green: #14720a; /* shadows --success — 5.89:1 card / 5.49:1 bg */
@@ -89,9 +89,9 @@ describe("CoachFeedbackSection", () => {
// Two bucket rows in the table.
const matches = (html.match(/coach-feedback-bucket/g) ?? []).length;
expect(matches).toBe(2);
// Helpful-rate column carries the band tint.
expect(html).toContain("text-dracula-green"); // 80% bucket
expect(html).toContain("text-dracula-yellow"); // 50% bucket
// Helpful-rate column carries the band tint (semantic urgency ramp).
expect(html).toContain("text-success"); // 80% bucket
expect(html).toContain("text-warning"); // 50% bucket
// Numbers visible in the markup.
expect(html).toContain("80%");
expect(html).toContain("50%");
+8 -5
View File
@@ -122,7 +122,7 @@ export interface ApiTokenInfo {
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
<Badge className="border-success/30 bg-success/15 text-success">
{t("admin.configured")}
</Badge>
);
@@ -488,11 +488,14 @@ export function useSystemStatus() {
}
/** Helpful-rate tint shared by the AI-quality + Coach-feedback tables.
* Green ≥80 %, yellow ≥50 %, orange below. */
* The standard urgency ramp (the medication-status-pill precedent):
* ≥80 % success, ≥50 % warning, below destructive — a sub-50 % helpful
* rate is a genuine quality alarm, and the percent label always carries
* the exact value alongside the tint. */
export function helpfulRateColour(rate: number): string {
if (rate >= 0.8) return "text-dracula-green";
if (rate >= 0.5) return "text-dracula-yellow";
return "text-dracula-orange";
if (rate >= 0.8) return "text-success";
if (rate >= 0.5) return "text-warning";
return "text-destructive";
}
export interface PublicVersion {
+4 -4
View File
@@ -46,10 +46,10 @@ interface FeedbackSummary {
}
const SEVERITY_TINT: Record<string, string> = {
info: "text-dracula-cyan",
suggestion: "text-dracula-purple",
important: "text-dracula-orange",
urgent: "text-dracula-red",
info: "text-info",
suggestion: "text-primary",
important: "text-warning",
urgent: "text-destructive",
};
export function AiQualitySection() {
@@ -92,9 +92,7 @@ function TokenStatusBadge({
);
}
return (
<Badge
className={`bg-dracula-green/15 text-dracula-green shrink-0 ${sizeClass}`}
>
<Badge className={`bg-success/15 text-success shrink-0 ${sizeClass}`}>
{t("common.active")}
</Badge>
);
@@ -75,12 +75,12 @@ function levelIcon(level: LogLevel) {
case "error":
return <AlertCircle className="text-destructive h-4 w-4" />;
case "warn":
return <AlertTriangle className="text-dracula-yellow h-4 w-4" />;
return <AlertTriangle className="text-warning h-4 w-4" />;
case "debug":
return <Info className="text-muted-foreground h-4 w-4" />;
case "info":
default:
return <CheckCircle2 className="text-dracula-green h-4 w-4" />;
return <CheckCircle2 className="text-success h-4 w-4" />;
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ export function DangerZoneSection() {
{wipeMsg && (
<p
className={`mt-3 pl-7 text-sm ${
wipeAllData.isError ? "text-destructive" : "text-dracula-green"
wipeAllData.isError ? "text-destructive" : "text-success"
}`}
>
{wipeMsg}
+11 -11
View File
@@ -145,20 +145,20 @@ const STATUS_STYLES: Record<InviteStatus, { dot: string; chip: string }> = {
// Quiet tinted chips with a colour dot — the dot carries the state at
// a glance, the tint stays low so a table of mixed states reads calm.
active: {
dot: "bg-dracula-green",
chip: "border-dracula-green/30 bg-dracula-green/10 text-dracula-green",
dot: "bg-success",
chip: "border-success/30 bg-success/10 text-success",
},
expired: {
dot: "bg-muted-foreground/60",
chip: "border-border bg-muted/40 text-muted-foreground",
},
exhausted: {
dot: "bg-dracula-cyan",
chip: "border-dracula-cyan/30 bg-dracula-cyan/10 text-dracula-cyan",
dot: "bg-info",
chip: "border-info/30 bg-info/10 text-info",
},
revoked: {
dot: "bg-dracula-red",
chip: "border-dracula-red/30 bg-dracula-red/10 text-dracula-red",
dot: "bg-destructive",
chip: "border-destructive/30 bg-destructive/10 text-destructive",
},
};
@@ -451,7 +451,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-dracula-red hover:text-dracula-red"
className="text-destructive hover:text-destructive"
aria-label={t("admin.invites.revokeAria")}
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
@@ -519,7 +519,7 @@ export function InviteTokensSection() {
type="button"
variant="ghost"
size="sm"
className="text-dracula-red hover:text-dracula-red"
className="text-destructive hover:text-destructive"
disabled={revoke.isPending}
onClick={() => setRevokeTarget(invite)}
>
@@ -582,7 +582,7 @@ export function InviteTokensSection() {
className={cn(
"min-h-11 rounded-lg border px-3 py-2 text-sm font-medium transition-colors",
ttlDays === days
? "border-dracula-purple/50 bg-dracula-purple/10 text-dracula-purple"
? "border-primary/50 bg-primary/10 text-primary"
: "border-border text-foreground hover:bg-accent",
)}
>
@@ -647,7 +647,7 @@ export function InviteTokensSection() {
</DialogHeader>
{/* The ticket — dashed edge marks the one-time nature. */}
<div
className="border-dracula-purple/40 bg-background rounded-xl border border-dashed p-4"
className="border-primary/40 bg-background rounded-xl border border-dashed p-4"
data-testid="admin-invites-minted"
>
<div className="flex flex-col items-center gap-4">
@@ -671,7 +671,7 @@ export function InviteTokensSection() {
>
{copied ? (
<Check
className="text-dracula-green size-4"
className="text-success size-4"
aria-hidden="true"
/>
) : (
@@ -464,7 +464,7 @@ export function LoginOverviewSection() {
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
<CheckCircle2 className="text-success h-4 w-4" />
)}
</td>
<td
@@ -503,7 +503,7 @@ export function LoginOverviewSection() {
</span>
{copiedIp === entry.id ? (
<Check
className="text-dracula-green h-3 w-3 shrink-0"
className="text-success h-3 w-3 shrink-0"
aria-hidden="true"
/>
) : (
@@ -115,7 +115,7 @@ export function RecentAuditPreview() {
{isFailed ? (
<XCircle className="text-destructive h-4 w-4" />
) : (
<CheckCircle2 className="text-dracula-green h-4 w-4" />
<CheckCircle2 className="text-success h-4 w-4" />
)}
</span>
<span
+6 -10
View File
@@ -70,7 +70,7 @@ export function SystemStatusSection() {
}
className={
status.database === "connected"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -108,9 +108,7 @@ export function SystemStatusSection() {
: t("admin.workerStopped")
}
className={
status.worker.running
? "text-dracula-green"
: "text-destructive"
status.worker.running ? "text-success" : "text-destructive"
}
/>
{status.worker.lastReminderCheck && (
@@ -131,7 +129,7 @@ export function SystemStatusSection() {
}
className={
status.integrations.umami.enabled
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -147,7 +145,7 @@ export function SystemStatusSection() {
}
className={
status.integrations.glitchtip.enabled
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -157,7 +155,7 @@ export function SystemStatusSection() {
icon={BellRing}
label={t("admin.integrationWebPush")}
value={t("admin.configured")}
className="text-dracula-green"
className="text-success"
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
@@ -175,9 +173,7 @@ export function SystemStatusSection() {
})
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
version.offlineGeoEnabled ? "text-success" : "text-warning"
}
/>
)}
@@ -61,7 +61,7 @@ export function SystemStatusSummary() {
}
className={
status.database === "connected"
? "text-dracula-green"
? "text-success"
: "text-destructive"
}
/>
@@ -74,7 +74,7 @@ export function SystemStatusSummary() {
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-dracula-green" : "text-destructive"
status.worker.running ? "text-success" : "text-destructive"
}
/>
<StatusItem
@@ -112,9 +112,7 @@ export function SystemStatusSummary() {
})
}
className={
version.offlineGeoEnabled
? "text-dracula-green"
: "text-dracula-yellow"
version.offlineGeoEnabled ? "text-success" : "text-warning"
}
/>
)}
@@ -190,7 +190,7 @@ export function UserManagementSection() {
<Button
variant="ghost"
size="sm"
className={`min-h-11 min-w-11 px-3 text-xs ${u.mfaEnforced ? "text-dracula-green" : ""}`}
className={`min-h-11 min-w-11 px-3 text-xs ${u.mfaEnforced ? "text-success" : ""}`}
onClick={() =>
updateUser.mutate({
id: u.id,
@@ -554,7 +554,7 @@ export function UserManagementSection() {
</div>
{resetMsg && (
<p
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-dracula-green" : "text-destructive"}`}
className={`text-sm ${resetMsg === t("admin.passwordReset") ? "text-success" : "text-destructive"}`}
>
{resetMsg}
</p>
@@ -97,7 +97,7 @@ export function VersionTileSection() {
}
target="_blank"
rel="noopener noreferrer"
className="bg-dracula-yellow/15 text-dracula-yellow border-dracula-yellow/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
className="bg-warning/15 text-warning border-warning/30 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity hover:opacity-80"
>
<ArrowUpCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpdateAvailable", {
@@ -106,7 +106,7 @@ export function VersionTileSection() {
})}
</a>
) : upToDate ? (
<span className="text-dracula-green inline-flex items-center gap-1.5 text-xs font-medium">
<span className="text-success inline-flex items-center gap-1.5 text-xs font-medium">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
{t("admin.overview.versionTileUpToDate")}
</span>
@@ -315,9 +315,7 @@ export function MedicationComplianceChart({
// mood tiles.
const trendColor = ((): string => {
if (!trend || trend.direction === "stable") return "text-muted-foreground";
return trend.direction === "up"
? "text-dracula-green"
: "text-dracula-orange";
return trend.direction === "up" ? "text-success" : "text-warning";
})();
const TrendIcon = !trend
@@ -62,22 +62,24 @@ describe("<ConfidenceMeter> bars variant (default)", () => {
expect(lit).toBe(2);
});
it("uses green band class at value>=80", () => {
it("uses the success tint at value>=80", () => {
const html = render(<ConfidenceMeter value={85} />);
expect(html).toContain('data-confidence-band="high"');
expect(html).toMatch(/dracula-green/);
expect(html).toMatch(/bg-success/);
});
it("uses yellow band class at value 50..79", () => {
it("uses the caution tint at value 50..79", () => {
const html = render(<ConfidenceMeter value={65} />);
expect(html).toContain('data-confidence-band="medium"');
expect(html).toMatch(/dracula-yellow/);
expect(html).toMatch(/bg-warning/);
});
it("uses orange band class at value 25..49", () => {
// Medium and low share the caution tint (coverage-meter precedent);
// the band attribute + lit-bar count carry the distinction.
it("uses the caution tint at value 25..49", () => {
const html = render(<ConfidenceMeter value={35} />);
expect(html).toContain('data-confidence-band="low"');
expect(html).toMatch(/dracula-orange/);
expect(html).toMatch(/bg-warning/);
});
it("renders the 'draft' pill INSTEAD of bars when value<25", () => {
@@ -148,13 +148,13 @@ describe("<RecommendationsGrid>", () => {
rec("d", "info", "info-rec"),
];
const html = render(<RecommendationsGrid recs={recs} />);
// Each card gets a Dracula-token border-l class. The exact class
// Each card gets a semantic-token border-l class. The exact class
// varies by severity; we assert at least one of each is in the
// output.
expect(html).toMatch(/border-l-dracula-red/);
expect(html).toMatch(/border-l-dracula-orange/);
expect(html).toMatch(/border-l-dracula-purple/);
expect(html).toMatch(/border-l-dracula-cyan/);
expect(html).toMatch(/border-l-destructive/);
expect(html).toMatch(/border-l-warning/);
expect(html).toMatch(/border-l-primary/);
expect(html).toMatch(/border-l-info/);
});
it("renders nothing when recs is empty", () => {
@@ -68,9 +68,9 @@ describe("<SuggestedPrompts>", () => {
expect(matches.length).toBe(2);
});
it("uses Dracula purple chip styling tokens", () => {
it("uses the primary-token chip styling", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toMatch(/border-dracula-purple\/18/);
expect(html).toMatch(/border-primary\/18/);
});
it("invokes onPick with the localised prompt string when a chip is clicked", () => {
@@ -726,7 +726,7 @@ export function CoachConversation({
) : null}
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br shadow-sm"
className="from-primary to-brand-pink flex size-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br shadow-sm"
>
<Sparkles className="text-background size-4" />
</div>
@@ -61,7 +61,7 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 -z-10",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--dracula-purple)_14%,transparent),transparent_70%)]",
"bg-[radial-gradient(60%_50%_at_50%_38%,color-mix(in_srgb,var(--primary)_14%,transparent),transparent_70%)]",
)}
/>
@@ -70,7 +70,7 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
<div
aria-hidden="true"
className={cn(
"from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"from-primary to-brand-pink flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-sm",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:duration-500",
)}
>
@@ -428,7 +428,7 @@ export function CoachInput({
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
? "text-brand-pink bg-brand-pink/10 hover:text-brand-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
@@ -553,7 +553,7 @@ export function CoachInput({
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"shadow-sm transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
"focus-within:border-primary/50 focus-within:ring-primary/15 focus-within:bg-background focus-within:ring-2",
// Both surfaces share ONE baseline row: the drawer flanks the
// textarea with mic (left) + send (right); the page leads with a
// `+` actions menu, then the textarea, then mic + send. `items-end`
@@ -601,7 +601,7 @@ export function CoachInput({
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
// scroll regions). Scoped here, not in globals.css.
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_35%,transparent)_transparent]",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
// Keep the placeholder a single line on narrow phones: a long
// hint used to wrap to two lines inside the one-row composer and
@@ -37,7 +37,7 @@ import { TypingDots } from "./message-thread";
/** Shared quiet text-button styling for the per-question actions. */
const guidedActionClass = cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
);
@@ -45,7 +45,7 @@ function GuidedAvatar({ icon: Icon }: { icon: typeof ClipboardCheck }) {
return (
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Icon className="text-background size-3.5" />
</div>
@@ -66,9 +66,8 @@ function ProgressDots({ current, total }: { current: number; total: number }) {
key={i}
className={cn(
"size-1.5 rounded-full",
i + 1 < current && "bg-dracula-purple",
i + 1 === current &&
"ring-dracula-purple bg-dracula-purple/40 ring-1",
i + 1 < current && "bg-primary",
i + 1 === current && "ring-primary bg-primary/40 ring-1",
i + 1 > current && "bg-muted-foreground/25",
)}
/>
@@ -125,7 +124,7 @@ export function GuidedQuestionBubble({
<div className="flex max-w-[calc(80%-2.625rem)] min-w-0 flex-col gap-1.5">
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"border-primary/30 bg-primary/10 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
@@ -134,7 +133,7 @@ export function GuidedQuestionBubble({
<>
<p
data-slot="coach-guided-progress"
className="text-dracula-purple mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
className="text-primary mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
>
{t("insights.coach.guided.progress", {
current: progress.current,
@@ -208,7 +207,7 @@ export function GuidedSummaryBubble({
<GuidedAvatar icon={ClipboardCheck} />
<div
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
"border-primary/30 bg-primary/10 text-foreground",
"max-w-[calc(80%-2.625rem)] min-w-0 rounded-xl rounded-tl-sm border px-3.5 py-2.5",
)}
>
@@ -226,8 +225,8 @@ export function GuidedSummaryBubble({
href="/settings/ai"
data-slot="coach-guided-summary-link"
className={cn(
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"focus-visible:ring-dracula-purple/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
"text-primary mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
"focus-visible:ring-primary/40 rounded hover:underline focus-visible:ring-2 focus-visible:outline-none",
)}
>
{t("insights.coach.guided.summaryLink")}
@@ -44,14 +44,14 @@ export function GuidedQuestionsCard({
<div
data-slot="coach-guided-offer"
className={cn(
"border-dracula-purple/30 from-dracula-purple/10 to-dracula-pink/5",
"border-primary/30 from-primary/10 to-brand-pink/5",
"mb-2 rounded-lg border bg-gradient-to-r px-3 py-2.5",
)}
>
<div className="flex items-start gap-2.5">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<MessageCircleQuestion className="text-background size-3.5" />
</div>
@@ -71,8 +71,8 @@ export function GuidedQuestionsCard({
disabled={disabled}
onClick={onStart}
className={cn(
"bg-dracula-purple/15 text-dracula-purple inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-dracula-purple/25 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"bg-primary/15 text-primary inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
"hover:bg-primary/25 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -85,7 +85,7 @@ export function GuidedQuestionsCard({
onClick={onLater}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -98,7 +98,7 @@ export function GuidedQuestionsCard({
onClick={onDismissAll}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs",
"hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/10 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -151,7 +151,7 @@ export function HistoryRail({
"group flex items-center gap-1.5 rounded-lg px-2.5 py-2",
"text-sm transition-colors",
isActive
? "bg-dracula-purple/15 text-foreground"
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
>
@@ -186,7 +186,7 @@ export function HistoryRail({
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming && "text-dracula-red hover:text-dracula-red",
isConfirming && "text-destructive hover:text-destructive",
)}
>
<Trash2 className="size-4" aria-hidden="true" />
@@ -181,14 +181,14 @@ export function placeInterleaved(
* is mixed down so the bar reads as a hairline accent, not a hard edge.
*/
export const COACH_SCROLLBAR = cn(
"[scrollbar-color:color-mix(in_srgb,var(--dracula-purple)_30%,transparent)_transparent] [scrollbar-width:thin]",
"[scrollbar-color:color-mix(in_srgb,var(--primary)_30%,transparent)_transparent] [scrollbar-width:thin]",
"[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-button]:hidden [&::-webkit-scrollbar-button]:size-0",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:border-[3px] [&::-webkit-scrollbar-thumb]:border-transparent [&::-webkit-scrollbar-thumb]:bg-clip-content",
"[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_30%,transparent)]",
"hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--dracula-purple)_45%,transparent)]",
"[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--primary)_30%,transparent)]",
"hover:[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--primary)_45%,transparent)]",
);
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
@@ -660,7 +660,7 @@ function CopyMessageButton({
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
@@ -897,7 +897,7 @@ export function MessageThread({
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink flex size-12 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-5" />
</div>
@@ -1257,7 +1257,7 @@ function ChatBubble({
>
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
{providerType === "refusal" ? (
<Bot className="text-background size-3.5" />
@@ -1545,7 +1545,7 @@ function RememberUserMessage({ content }: { content: string }) {
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
>
<Check className="text-dracula-green size-3" aria-hidden="true" />
<Check className="text-success size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
@@ -1616,7 +1616,7 @@ export function TypingDots({ label }: { label: string }) {
{[0, 1, 2].map((i) => (
<span
key={i}
className="bg-dracula-purple/70 size-1.5 animate-bounce rounded-full motion-reduce:animate-none"
className="bg-primary/70 size-1.5 animate-bounce rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 150}ms`, animationDuration: "1s" }}
/>
))}
@@ -64,7 +64,7 @@ export function ReminderSuggestionCard({
data-slot="coach-reminder-suggestion-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
settled === "accepted"
? "coach.reminderSuggestion.accepted"
@@ -89,7 +89,7 @@ export function ReminderSuggestionCard({
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-brand-pink mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<BellPlus className="text-background size-3.5" />
</div>
@@ -111,7 +111,7 @@ export function ReminderSuggestionCard({
onClick={() => act.mutate("accept")}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
@@ -83,10 +83,7 @@ export function ScopeHintBadge({
"text-xs font-medium",
)}
>
<Icon
className="text-dracula-purple size-3.5 shrink-0"
aria-hidden="true"
/>
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{prefix}</span>
</span>
@@ -76,7 +76,7 @@ export function SelfContextAdoptOffer({
<div
data-slot="coach-self-context-adopt"
className={cn(
"border-dracula-purple/30 bg-dracula-purple/10 mb-2 flex items-center gap-2 rounded-lg border px-3 py-2",
"border-primary/30 bg-primary/10 mb-2 flex items-center gap-2 rounded-lg border px-3 py-2",
)}
>
{settled ? (
@@ -85,7 +85,7 @@ export function SelfContextAdoptOffer({
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check
className="text-dracula-green size-3.5 shrink-0"
className="text-success size-3.5 shrink-0"
aria-hidden="true"
/>
{t(
@@ -97,7 +97,7 @@ export function SelfContextAdoptOffer({
) : (
<>
<BookmarkPlus
className="text-dracula-purple size-3.5 shrink-0"
className="text-primary size-3.5 shrink-0"
aria-hidden="true"
/>
<p className="text-muted-foreground min-w-0 flex-1 text-xs">
@@ -109,8 +109,8 @@ export function SelfContextAdoptOffer({
disabled={adopt.isPending}
onClick={() => adopt.mutate()}
className={cn(
"text-dracula-purple inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"text-primary inline-flex min-h-8 shrink-0 items-center gap-1 rounded-full px-2.5 text-xs font-medium",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
"disabled:opacity-60",
)}
>
@@ -132,7 +132,7 @@ export function SelfContextAdoptOffer({
}}
className={cn(
"text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5",
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
"hover:bg-primary/15 focus-visible:ring-primary/40 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-3" aria-hidden="true" />
@@ -86,7 +86,7 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-dracula-cyan/25 text-dracula-cyan/90",
"border-info/25 text-info/90",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-[11px] leading-none",
)}
@@ -139,10 +139,10 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
{activeScopeLabel ? (
<div
data-slot="coach-sources-active-scope"
className="border-dracula-purple/30 bg-dracula-purple/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
className="border-primary/30 bg-primary/5 text-foreground flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium"
>
<Target
className="text-dracula-purple size-3.5 shrink-0"
className="text-primary size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 truncate">
@@ -50,7 +50,7 @@ export function SuggestedActionCard({
data-slot="coach-suggested-action-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-dracula-green size-3.5" aria-hidden="true" />
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
settled === "applied"
? "coach.suggestedAction.applied"
@@ -77,7 +77,7 @@ export function SuggestedActionCard({
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="from-dracula-purple to-dracula-cyan mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
className="from-primary to-info mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
>
<Sparkles className="text-background size-3.5" />
</div>
@@ -102,7 +102,7 @@ export function SuggestedActionCard({
onClick={() => apply.mutate()}
disabled={busy}
className={cn(
"bg-dracula-purple/90 text-background hover:bg-dracula-purple",
"bg-primary/90 text-background hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
+5 -1
View File
@@ -12,5 +12,9 @@ export type ConfidenceBand = "low" | "moderate" | "high";
export const CONFIDENCE_BADGE_CLASS: Record<ConfidenceBand, string> = {
high: "border-success/40 bg-success/10 text-success",
moderate: "border-warning/40 bg-warning/10 text-warning",
low: "border-dracula-comment/40 bg-dracula-comment/10 text-muted-foreground",
// Neutral (non-status) tier. The former `*-dracula-comment/*` classes
// had no generated utility (`--color-dracula-comment` was never in the
// @theme block) and silently painted nothing; border/muted is the real
// neutral vocabulary.
low: "border-border bg-muted/50 text-muted-foreground",
};
+11 -7
View File
@@ -47,22 +47,26 @@ function litBarsFor(value: number): number {
return lit;
}
// Semantic severities only (the coverage-meter precedent): medium and
// low both ride the caution `--warning` — the lit-bar count and the
// numeric aria-label carry the medium↔low distinction, never colour
// alone.
const BAR_LIT_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "bg-dracula-green",
medium: "bg-dracula-yellow",
low: "bg-dracula-orange",
high: "bg-success",
medium: "bg-warning",
low: "bg-warning",
};
const RING_STROKE_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "stroke-dracula-green",
medium: "stroke-dracula-yellow",
low: "stroke-dracula-orange",
high: "stroke-success",
medium: "stroke-warning",
low: "stroke-warning",
};
function DraftPill({ ariaLabel }: { ariaLabel: string }) {
@@ -72,7 +76,7 @@ function DraftPill({ ariaLabel }: { ariaLabel: string }) {
data-confidence-band="draft"
role="img"
aria-label={ariaLabel}
className="bg-dracula-red/10 text-dracula-red border-dracula-red/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.recommendation.confidenceDraft")}
</span>
+7 -6
View File
@@ -51,9 +51,9 @@ interface CorrelationCardProps {
}
const TONE_BAR_CLASSNAME: Record<CorrelationResult["kind"], string> = {
"bp-compliance": "bg-dracula-pink",
"mood-pulse": "bg-dracula-cyan",
"weight-weekday": "bg-dracula-purple",
"bp-compliance": "bg-brand-pink",
"mood-pulse": "bg-info",
"weight-weekday": "bg-primary",
};
const TITLE_KEY: Record<CorrelationResult["kind"], string> = {
@@ -208,12 +208,13 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
}
function tonesByKind(kind: CorrelationResult["kind"]): string {
// Keep in step with TONE_BAR_CLASSNAME above — same token per kind.
switch (kind) {
case "bp-compliance":
return "var(--dracula-pink)";
return "var(--brand-pink)";
case "mood-pulse":
return "var(--dracula-cyan)";
return "var(--info)";
case "weight-weekday":
return "var(--dracula-purple)";
return "var(--primary)";
}
}
+1 -1
View File
@@ -285,7 +285,7 @@ export function HeroStrip({
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1
+3 -3
View File
@@ -252,8 +252,8 @@ export function LayoutCoachFab() {
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.
"from-dracula-purple to-dracula-pink text-background bg-gradient-to-br",
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
"from-primary to-brand-pink text-background bg-gradient-to-br",
"hover:from-primary/90 hover:to-brand-pink/90",
// The default ring alone is hard to see against the gradient;
// the offset ring draws a clear halo around the circle.
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
@@ -285,7 +285,7 @@ export function LayoutCoachFab() {
<span
data-slot="coach-fab-unread"
aria-hidden="true"
className="border-background bg-dracula-cyan absolute top-0.5 right-0.5 size-3 rounded-full border-2"
className="border-background bg-info absolute top-0.5 right-0.5 size-3 rounded-full border-2"
/>
) : null}
</Button>
@@ -102,7 +102,7 @@ export function MeasurementDiversityNudge({
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="text-warning hover:text-warning/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
@@ -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";
@@ -91,11 +91,11 @@ export function SuggestedPrompts({
// target clears the 44 px iOS/WCAG floor on phones (`min-h-11`)
// and relaxes to the denser 36 px from `sm` up.
className={cn(
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"border-primary/18 hover:border-primary/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9",
"rounded-full border bg-transparent px-3 py-1.5 text-xs",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
"focus-visible:ring-primary/50",
)}
>
<Quote className="h-3 w-3 shrink-0" aria-hidden="true" />
+1 -1
View File
@@ -86,7 +86,7 @@ export function TrendCaptionCard({
className="border-border/60 bg-card/40 flex items-start gap-2 rounded-md border p-3"
>
<Sparkles
className="text-dracula-purple mt-0.5 h-3.5 w-3.5 shrink-0"
className="text-primary mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
@@ -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({
@@ -125,7 +125,7 @@ export function ResearchModeAcknowledgmentDialog({
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<BookOpenCheck className="text-dracula-purple h-5 w-5 shrink-0" />
<BookOpenCheck className="text-primary h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</span>
}
@@ -171,7 +171,7 @@ export function ScheduleHistoryTimeline({
<ol className="space-y-0">
{/* Live plan — the accent node. */}
<TimelineRow
dotClassName="bg-dracula-green"
dotClassName="bg-success"
railVisible={expanded && revisions.length > 0}
dataSlot="zeitplan-history-current"
>
@@ -208,7 +208,7 @@ export function ScheduleHistoryTimeline({
: t("medications.detail.zeitplan.history.noTimes")}
{revision.source === "MANUAL" && (
<span
className="border-dracula-purple/30 bg-dracula-purple/10 text-dracula-purple ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
className="border-primary/30 bg-primary/10 text-primary ml-2 inline-flex rounded-full border px-2 py-px align-middle text-[11px] font-medium"
data-slot="zeitplan-history-manual-chip"
>
{t("medications.detail.zeitplan.history.manualChip")}
@@ -240,7 +240,7 @@ export function ScheduleHistoryTimeline({
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-dracula-red size-8"
className="text-muted-foreground hover:text-destructive size-8"
aria-label={t(
"medications.detail.zeitplan.history.deleteAria",
)}
@@ -569,7 +569,7 @@ function EraDialog({
</p>
{validationKey !== null && (
<p
className="text-dracula-red text-sm"
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
+2 -2
View File
@@ -688,7 +688,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
key={entry.id}
data-testid="mood-row"
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 flex items-center justify-between rounded-lg border p-3"
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5 flex items-center justify-between rounded-lg border p-3"
>
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
@@ -1098,7 +1098,7 @@ function MoodNoteText({
aria-expanded={expanded}
aria-controls={noteId}
data-testid="mood-note-toggle"
className="text-dracula-purple cursor-pointer text-[11px] font-medium"
className="text-primary cursor-pointer text-[11px] font-medium"
>
{expanded ? t("mood.noteCollapse") : t("mood.noteExpand")}
</button>
+1 -1
View File
@@ -74,7 +74,7 @@ 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>
+5 -5
View File
@@ -192,7 +192,7 @@ export function AboutMeSection({
aria-hidden="true"
className={cn(
"h-1 w-5 rounded-full transition-colors",
i < answered ? "bg-dracula-purple" : "bg-border",
i < answered ? "bg-primary" : "bg-border",
)}
/>
))}
@@ -272,11 +272,11 @@ export function AboutMeSection({
{pendingQuestions.length > 0 && (
<div
data-testid="settings-about-me-questions"
className="border-dracula-purple/30 bg-dracula-purple/5 rounded-lg border p-4"
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-dracula-purple size-4 shrink-0"
className="text-primary size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
@@ -289,7 +289,7 @@ export function AboutMeSection({
<p className="mt-3 text-xs">
<Link
href="/coach"
className="text-dracula-purple underline-offset-4 hover:underline"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
@@ -318,7 +318,7 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
className="text-dracula-purple underline-offset-4 hover:underline"
className="text-primary underline-offset-4 hover:underline"
>
{t("settings.ai.aboutMe.recordsLinkCta")}
</Link>
@@ -67,7 +67,7 @@ export function CoachRemindersSection({
data-status={r.status}
className={
highlighted
? "border-dracula-purple/40 bg-dracula-purple/5 flex flex-col gap-2 rounded-lg border p-3"
? "border-primary/40 bg-primary/5 flex flex-col gap-2 rounded-lg border p-3"
: "border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
}
>
@@ -20,7 +20,7 @@ function render(props: Parameters<typeof RangeBar>[0]) {
}
describe("<RangeBar>", () => {
it("renders the bar slot + green/orange/red zones with Dracula tokens", () => {
it("renders the bar slot + green/orange/red zones with semantic tokens", () => {
const html = render({
value: 72,
min: 60,
@@ -28,9 +28,9 @@ describe("<RangeBar>", () => {
unit: "bpm",
});
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain("bg-dracula-green/20");
expect(html).toContain("bg-dracula-orange/15");
expect(html).toContain("bg-dracula-red/10");
expect(html).toContain("bg-success/20");
expect(html).toContain("bg-warning/15");
expect(html).toContain("bg-destructive/10");
// Pin the absence of the legacy raw Tailwind palette so the swap
// doesn't silently regress on future merges.
expect(html).not.toContain("bg-green-500/20");
@@ -38,23 +38,23 @@ describe("<RangeBar>", () => {
expect(html).not.toContain("bg-red-500/8");
});
it("paints the marker with the green token when the value is in range", () => {
it("paints the marker with the success token when the value is in range", () => {
const html = render({
value: 80,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain("var(--dracula-green)");
expect(html).toContain("var(--success)");
});
it("paints the marker with the red token when the value is out of band", () => {
it("paints the marker with the destructive token when the value is out of band", () => {
const html = render({
value: 180,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--destructive)");
});
});
+3 -3
View File
@@ -54,9 +54,9 @@ export interface ConsistencyStripProps {
// encoded inside the value via color-mix so the opacity modifier is
// no longer needed and the bracketed form parses cleanly.
const BAND_STYLES: Record<NonNullable<ConsistencyBand>, string> = {
in: "bg-(--dracula-green) border-(--dracula-green) shadow-[0_0_0_1px_color-mix(in_oklab,var(--dracula-green)_20%,transparent)]",
near: "bg-(--dracula-orange) border-(--dracula-orange)",
out: "bg-(--dracula-red) border-(--dracula-red)",
in: "bg-success border-success shadow-[0_0_0_1px_color-mix(in_oklab,var(--success)_20%,transparent)]",
near: "bg-warning border-warning",
out: "bg-destructive border-destructive",
};
const BAND_ARIA: Record<NonNullable<ConsistencyBand>, string> = {
+7 -7
View File
@@ -76,10 +76,10 @@ export function RangeBar({
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
const markerColor = inGreen
? "var(--dracula-green)"
? "var(--success)"
: inYellow
? "var(--dracula-orange)"
: "var(--dracula-red)";
? "var(--warning)"
: "var(--destructive)";
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
@@ -99,18 +99,18 @@ export function RangeBar({
chart palette stays aligned with PR-badge, alerts, and the
marker dot itself. Raw Tailwind palettes drift from the
theme over time and never get dark-mode tuned. */}
<div className="bg-dracula-red/10 absolute inset-0 rounded-full" />
<div className="bg-destructive/10 absolute inset-0 rounded-full" />
{/* Orange (caution) side zones — `--dracula-orange` matches the
marker's out-of-band tint. */}
<div
className="bg-dracula-orange/15 absolute top-0 h-full"
className="bg-warning/15 absolute top-0 h-full"
style={{
left: `${yellowLeftStart}%`,
width: `${greenStart - yellowLeftStart}%`,
}}
/>
<div
className="bg-dracula-orange/15 absolute top-0 h-full"
className="bg-warning/15 absolute top-0 h-full"
style={{
left: `${greenEnd}%`,
width: `${yellowRightEnd - greenEnd}%`,
@@ -118,7 +118,7 @@ export function RangeBar({
/>
{/* In-band green zone — `--dracula-green`. */}
<div
className="bg-dracula-green/20 absolute top-0 h-full"
className="bg-success/20 absolute top-0 h-full"
style={{
left: `${greenStart}%`,
width: `${greenEnd - greenStart}%`,
+1 -1
View File
@@ -26,7 +26,7 @@ function Checkbox({
data-slot="checkbox"
className={cn(
"peer border-border/70 size-4 shrink-0 rounded border",
"data-[state=checked]:bg-dracula-purple data-[state=checked]:border-dracula-purple data-[state=checked]:text-background",
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
"transition-colors",