refactor(theming): migrate 6 ListViews + ai-missions badges to theme tokens

Replace raw white-alpha Tailwind utilities (text-white/x, bg-white/x,
border-white/x) with canonical theme tokens (text-foreground, bg-muted,
border-border, etc.) in cards, context, food, moodlit, storage, music
ListViews. Replace hardcoded hex badge/dot/phase colors in ai-missions
with success/warning/error/primary tokens.

Fix two transition-all bugs (food:160, moodlit:223) that prevented CSS
custom property colors from resolving on first paint under theme switches.

Add scripts/validate-theme-tokens.mjs to prevent regression; run via
pnpm run validate:theme-tokens. Not yet in validate:all — 12 modules
still use raw white utilities (citycorners, guides, inventory, memoro,
picture, plants, playground, presi, questions, times, uload, who).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 15:23:55 +02:00
parent dc22240483
commit a2a43b1d5a
9 changed files with 188 additions and 75 deletions

View file

@ -564,16 +564,16 @@
border-radius: 999px;
}
.dot-active {
background: #22c55e;
background: hsl(var(--color-success));
}
.dot-paused {
background: #f59e0b;
background: hsl(var(--color-warning));
}
.dot-done {
background: #6b7280;
background: hsl(var(--color-muted-foreground));
}
.dot-archived {
background: #374151;
background: hsl(var(--color-muted-foreground) / 0.5);
}
.m-meta {
display: flex;
@ -730,24 +730,24 @@
text-transform: uppercase;
}
.badge-awaiting-review {
background: #fef0c9;
color: #8a5a00;
background: hsl(var(--color-warning) / 0.18);
color: hsl(var(--color-warning));
}
.badge-approved {
background: #d7f7e3;
color: #1b7a3a;
background: hsl(var(--color-success) / 0.18);
color: hsl(var(--color-success));
}
.badge-rejected,
.badge-failed {
background: #f7d7d7;
color: #8a1b1b;
background: hsl(var(--color-error) / 0.18);
color: hsl(var(--color-error));
}
.badge-running {
background: #d7ecff;
color: #0a548b;
background: hsl(var(--color-primary) / 0.18);
color: hsl(var(--color-primary));
}
.it-running {
border-color: color-mix(in oklab, #0a548b 35%, hsl(var(--color-border)));
border-color: color-mix(in oklab, hsl(var(--color-primary)) 35%, hsl(var(--color-border)));
}
.phase-block {
display: flex;
@ -755,7 +755,7 @@
gap: 0.375rem;
padding: 0.5rem 0.625rem;
margin: 0.375rem 0;
background: color-mix(in oklab, #0a548b 6%, transparent);
background: hsl(var(--color-primary) / 0.08);
border-radius: 0.375rem;
}
.phase-line {
@ -806,8 +806,8 @@
font-size: 0.75rem;
}
.cancel-btn:hover:not(:disabled) {
color: #8a1b1b;
border-color: #e99;
color: hsl(var(--color-error));
border-color: hsl(var(--color-error) / 0.5);
}
.cancel-btn:disabled {
opacity: 0.5;
@ -815,10 +815,10 @@
}
.err-details {
margin-top: 0.375rem;
border: 1px solid #f7d7d7;
border: 1px solid hsl(var(--color-error) / 0.3);
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
background: color-mix(in oklab, #8a1b1b 4%, transparent);
background: hsl(var(--color-error) / 0.05);
font-size: 0.8125rem;
}
.err-details summary {
@ -830,7 +830,7 @@
.err-name {
font-family: var(--font-mono, ui-monospace, monospace);
font-weight: 600;
color: #8a1b1b;
color: hsl(var(--color-error));
}
.err-phase {
color: hsl(var(--color-muted-foreground));
@ -838,7 +838,7 @@
}
.err-message {
margin: 0.375rem 0 0;
color: #6a1515;
color: hsl(var(--color-error));
word-break: break-word;
}
.err-stack {
@ -931,12 +931,12 @@
border-radius: 999px;
}
.grant-pill-ok {
background: #d7f7e3;
color: #1b7a3a;
background: hsl(var(--color-success) / 0.18);
color: hsl(var(--color-success));
}
.grant-pill-warn {
background: #fde7c8;
color: #8a4f00;
background: hsl(var(--color-warning) / 0.18);
color: hsl(var(--color-warning));
}
.grant-pill-muted {
background: hsl(var(--color-surface));

View file

@ -37,7 +37,7 @@
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks">
{#snippet header()}
<span class="flex-1">{decks.length} Decks</span>
<span class="text-amber-400/70">{dueForReview} fällig</span>
<span class="text-warning/80">{dueForReview} fällig</span>
{/snippet}
{#snippet item(deck)}
@ -48,15 +48,15 @@
_siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
class="mb-2 w-full rounded-md border border-border px-3 py-2.5 text-left transition-colors hover:bg-muted/50 min-h-[44px]"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
<p class="flex-1 truncate text-sm font-medium text-white/80">{deck.name}</p>
<span class="text-xs text-white/40">{cardsInDeck(deck.id)}</span>
<p class="flex-1 truncate text-sm font-medium text-foreground">{deck.name}</p>
<span class="text-xs text-muted-foreground">{cardsInDeck(deck.id)}</span>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/40">{deck.description}</p>
<p class="mt-1 truncate text-xs text-muted-foreground">{deck.description}</p>
{/if}
</button>
{/snippet}

View file

@ -60,14 +60,14 @@
<div class="flex items-center justify-between gap-2">
<a
href="/context/documents"
class="text-xs text-white/50 transition-colors hover:text-white/80"
class="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Alle Dokumente &rarr;
</a>
<button
type="button"
onclick={handleCreateDocument}
class="flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<span aria-hidden="true">+</span>
Neues Dokument
@ -82,30 +82,30 @@
{#snippet listHeader()}
{#if pinnedSpaces.length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Angepinnte Spaces</h3>
<h3 class="mb-2 text-xs font-medium text-muted-foreground">Angepinnte Spaces</h3>
{#each pinnedSpaces as space (space.id)}
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<p class="text-sm font-medium text-white/80">{space.name}</p>
<div class="mb-1 min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-muted/50">
<p class="text-sm font-medium text-foreground">{space.name}</p>
{#if space.description}
<p class="truncate text-xs text-white/30">{space.description}</p>
<p class="truncate text-xs text-muted-foreground/70">{space.description}</p>
{/if}
</div>
{/each}
{/if}
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt bearbeitet</h3>
<h3 class="mb-2 mt-3 text-xs font-medium text-muted-foreground">Zuletzt bearbeitet</h3>
{/snippet}
{#snippet item(doc)}
<a
href="/context/documents/{doc.id}"
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
>
<span class="text-sm">{@html typeIcons[doc.type] ?? '&#128196;'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/70">{doc.title || 'Unbenannt'}</p>
<p class="truncate text-sm text-foreground/90">{doc.title || 'Unbenannt'}</p>
</div>
{#if doc.pinned}
<span class="text-xs text-white/30">&#128204;</span>
<span class="text-xs text-muted-foreground/70">&#128204;</span>
{/if}
</a>
{/snippet}

View file

@ -146,8 +146,8 @@
{#snippet toolbar()}
<!-- Calorie progress -->
<div class="text-center">
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
<p class="text-xs text-white/40">
<p class="text-2xl font-light text-foreground">{Math.round(totalCalories)}</p>
<p class="text-xs text-muted-foreground">
{#if goal}
von {goal.dailyCalories} kcal
{:else}
@ -155,12 +155,10 @@
{/if}
</p>
{#if goal}
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-muted">
<div
class="h-full rounded-full transition-all {calorieProgress >= 100
? 'bg-green-400'
: 'bg-blue-400'}"
style="width: {calorieProgress}%"
class="h-full rounded-full {calorieProgress >= 100 ? 'bg-success' : 'bg-primary'}"
style="width: {calorieProgress}%; transition: width 0.3s ease;"
></div>
</div>
{/if}
@ -174,7 +172,7 @@
onkeydown={onTextKeydown}
placeholder="Was hast du gegessen?"
disabled={quickSaving}
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/90 placeholder:text-white/30 focus:border-white/20 focus:outline-none disabled:opacity-50"
class="flex-1 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/60 focus:border-ring focus:outline-none disabled:opacity-50"
/>
<button
type="button"
@ -182,7 +180,7 @@
disabled={!quickText.trim() || quickSaving}
aria-label="Mahlzeit speichern"
title="Speichern"
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-foreground/80 transition-colors hover:bg-muted disabled:opacity-30"
>
{quickSaving ? '…' : '↵'}
</button>
@ -196,25 +194,25 @@
disabled={photoUploading}
aria-label="Foto hinzufuegen"
title="Foto"
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/70 transition-colors hover:bg-white/10 disabled:opacity-30"
class="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-foreground/80 transition-colors hover:bg-muted disabled:opacity-30"
>
{photoUploading ? '...' : '📷'}
</button>
{#if showPhotoMenu}
<div
class="absolute bottom-full right-0 z-10 mb-1 flex flex-col overflow-hidden rounded-lg border border-white/10 bg-[hsl(var(--color-card))] shadow-lg"
class="absolute bottom-full right-0 z-10 mb-1 flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-lg"
>
<button
type="button"
onclick={openCamera}
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-white/80 transition-colors hover:bg-white/10"
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-muted"
>
<span>📸</span> Kamera
</button>
<button
type="button"
onclick={openGallery}
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-white/80 transition-colors hover:bg-white/10"
class="flex items-center gap-2 whitespace-nowrap px-4 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-muted"
>
<span>🖼️</span> Mediathek
</button>
@ -250,19 +248,19 @@
{#snippet item(meal)}
<a
href="/food/{meal.id}"
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-white/5"
class="mb-1 block min-h-[44px] rounded-md px-3 py-2 transition-colors hover:bg-muted/50"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs text-white/50"
<span class="text-xs text-muted-foreground"
>{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
>
{#if meal.inputType === 'photo'}
<span class="text-xs text-white/40">📷</span>
<span class="text-xs text-muted-foreground/80">📷</span>
{/if}
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
<p class="truncate text-sm text-foreground/90">{meal.description}</p>
</div>
{#if meal.photoThumbnailUrl || meal.photoUrl}
<img
@ -273,7 +271,7 @@
/>
{/if}
{#if meal.nutrition}
<span class="whitespace-nowrap text-xs text-white/50"
<span class="whitespace-nowrap text-xs text-muted-foreground"
>{Math.round(meal.nutrition.calories)} kcal</span
>
{/if}

View file

@ -33,7 +33,7 @@
}
function gradientStyle(colors: string[]): string {
if (colors.length === 0) return 'background: #333';
if (colors.length === 0) return 'background: hsl(var(--color-muted))';
if (colors.length === 1) return `background: ${colors[0]}`;
return `background: linear-gradient(135deg, ${colors.join(', ')})`;
}
@ -178,7 +178,7 @@
{#if newColors.length > 1}
<button
type="button"
class="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[8px] text-white"
class="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-error text-[8px] text-white"
onclick={() => removeColor(i)}>x</button
>
{/if}
@ -206,7 +206,7 @@
<!-- Save -->
<button
type="button"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!newName.trim() || newColors.length === 0}
onclick={handleCreate}
>
@ -220,7 +220,8 @@
<button
onclick={() => (fullscreenMood = mood)}
oncontextmenu={(e) => ctxMenu.open(e, mood)}
class="mood-card group relative aspect-[4/3] w-full select-none overflow-hidden rounded-xl border-2 border-transparent transition-all duration-200 [-webkit-touch-callout:none] hover:border-white/40 focus:outline-none"
class="mood-card group relative aspect-[4/3] w-full select-none overflow-hidden rounded-xl border-2 border-transparent [-webkit-touch-callout:none] focus:outline-none"
style:transition="border-color 0.2s, transform 0.2s"
style="--mood-color: {mood.colors[0]}"
>
<div
@ -252,6 +253,13 @@
/>
<style>
/* Mood cards always sit on a vivid colour gradient, so the hover ring
is intentionally white (brand literal, not theme-intent — see
packages/shared-tailwind/src/themes.css §4). */
.mood-card:hover {
border-color: rgba(255, 255, 255, 0.4);
}
.anim-gradient {
animation: gradient-shift 8s ease infinite;
}

View file

@ -261,7 +261,7 @@
{/snippet}
{#snippet listHeader()}
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
<h3 class="mb-2 text-xs font-medium text-muted-foreground">Zuletzt gehört</h3>
{/snippet}
{#snippet item(song)}
@ -272,18 +272,18 @@
_siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId',
})}
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5 cursor-pointer text-left"
class="flex w-full min-h-[44px] items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50 cursor-pointer text-left"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted text-xs text-muted-foreground"
>
&#9835;
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{song.title}</p>
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
<p class="truncate text-sm text-foreground">{song.title}</p>
<p class="truncate text-xs text-muted-foreground">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
<span class="text-xs text-muted-foreground/70">{formatDuration(song.duration)}</span>
</button>
{/snippet}
</BaseListView>

View file

@ -55,17 +55,20 @@
{#snippet listHeader()}
{#if rootFolders.length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Ordner</h3>
<h3 class="mb-2 text-xs font-medium text-muted-foreground">Ordner</h3>
{#each rootFolders as folder (folder.id)}
<div
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
class="flex min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/50"
>
<span class="text-sm" style="color: {folder.color ?? '#6b7280'}">&#128193;</span>
<span class="truncate text-sm text-white/70">{folder.name}</span>
<span
class="text-sm"
style="color: {folder.color ?? 'hsl(var(--color-muted-foreground))'}">&#128193;</span
>
<span class="truncate text-sm text-foreground/90">{folder.name}</span>
</div>
{/each}
{/if}
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt</h3>
<h3 class="mb-2 mt-3 text-xs font-medium text-muted-foreground">Zuletzt</h3>
{/snippet}
{#snippet item(file)}
@ -76,11 +79,11 @@
_siblingIds: recentFiles.map((f) => f.id),
_siblingKey: 'fileId',
})}
class="flex w-full min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
class="flex w-full min-h-[44px] items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
>
<span class="text-sm">{@html fileIcon(file.mimeType)}</span>
<span class="min-w-0 flex-1 truncate text-sm text-white/70">{file.name}</span>
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span>
<span class="min-w-0 flex-1 truncate text-sm text-foreground/90">{file.name}</span>
<span class="shrink-0 text-xs text-muted-foreground/70">{formatSize(file.size)}</span>
</button>
{/snippet}
</BaseListView>

View file

@ -21,6 +21,7 @@
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
"validate:turbo": "node scripts/validate-no-recursive-turbo.mjs",
"validate:pg-schema": "node scripts/validate-pg-schema-isolation.mjs",
"validate:theme-tokens": "node scripts/validate-theme-tokens.mjs",
"validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run check:crypto",
"check:crypto": "node scripts/audit-crypto-registry.mjs",
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed",

View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Validate that module ListView.svelte files use theme tokens instead of
* raw white-alpha Tailwind utilities.
*
* Background: the unified Mana app supports multiple theme variants (Lume,
* Nature, Stone, Ocean, plus dark). Utilities like `text-white/80`,
* `bg-white/5`, `border-white/10` ignore the active theme and can render
* white-on-white in light variants. The theme tokens (`text-foreground`,
* `bg-muted`, `border-border`, etc.) are the canonical replacements
* they're generated by Tailwind v4 from `packages/shared-tailwind/src/themes.css`
* and resolve per-theme automatically.
*
* Rule: `src/lib/modules/**\/ListView.svelte` must not contain
* - `bg-white/` (e.g. bg-white/5, bg-white/10, hover:bg-white/5)
* - `text-white/` (e.g. text-white/80, hover:text-white/90)
* - `border-white/` (e.g. border-white/10, focus:border-white/20)
*
* Suggested replacements:
* bg-white/N bg-muted/N or bg-card
* text-white text-foreground
* text-white/40-60 text-muted-foreground
* border-white/N border-border
*
* `text-white` without an opacity modifier is allowed when it sits on a
* guaranteed-dark surface (mood gradient overlays, photo viewer) those
* are brand literals per the themes.css policy.
*
* Zero deps runs as plain Node ESM. Uses `git ls-files` to respect
* .gitignore.
*/
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..');
const LIST_VIEW_GLOB = 'apps/mana/apps/web/src/lib/modules/*/ListView.svelte';
// `\b` before ensures we catch `hover:bg-white/`, `focus:border-white/`,
// etc., without matching unrelated class names like `off-white`.
const FORBIDDEN = /(?:^|[\s:"'`])(bg|text|border)-white\//g;
function listListViews() {
const out = execSync(`git ls-files "${LIST_VIEW_GLOB}"`, {
cwd: REPO_ROOT,
encoding: 'utf8',
});
return out
.split('\n')
.map((p) => p.trim())
.filter(Boolean);
}
function validate() {
const paths = listListViews();
const violations = [];
for (const rel of paths) {
const abs = join(REPO_ROOT, rel);
const src = readFileSync(abs, 'utf8');
const lines = src.split('\n');
lines.forEach((line, i) => {
FORBIDDEN.lastIndex = 0;
let match;
while ((match = FORBIDDEN.exec(line)) !== null) {
violations.push({
file: rel,
line: i + 1,
token: `${match[1]}-white/`,
snippet: line.trim().slice(0, 120),
});
}
});
}
if (violations.length > 0) {
console.error(`\n✗ Theme-token check FAILED (${violations.length} violation(s)):\n`);
for (const v of violations) {
console.error(`${v.file}:${v.line} [${v.token}]`);
console.error(` ${v.snippet}`);
}
console.error(
`\nReplace raw white-alpha utilities with theme tokens:\n` +
` bg-white/N → bg-muted/N or bg-card\n` +
` text-white → text-foreground\n` +
` text-white/40-60 → text-muted-foreground\n` +
` border-white/N → border-border\n\n` +
`Rationale: raw white utilities ignore theme variants (Lume, Nature, ...)\n` +
`and can render white-on-white under light themes. See\n` +
`packages/shared-tailwind/src/themes.css for the full token set.\n`
);
process.exit(1);
}
console.log(`✓ Theme-token check: ${paths.length} ListView(s) use theme tokens correctly.`);
}
validate();