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

View file

@ -37,7 +37,7 @@
<BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks"> <BaseListView items={decks} getKey={(d) => d.id} emptyTitle="Keine Decks">
{#snippet header()} {#snippet header()}
<span class="flex-1">{decks.length} Decks</span> <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}
{#snippet item(deck)} {#snippet item(deck)}
@ -48,15 +48,15 @@
_siblingIds: decks.map((d) => d.id), _siblingIds: decks.map((d) => d.id),
_siblingKey: 'deckId', _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="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div> <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> <p class="flex-1 truncate text-sm font-medium text-foreground">{deck.name}</p>
<span class="text-xs text-white/40">{cardsInDeck(deck.id)}</span> <span class="text-xs text-muted-foreground">{cardsInDeck(deck.id)}</span>
</div> </div>
{#if deck.description} {#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} {/if}
</button> </button>
{/snippet} {/snippet}

View file

@ -60,14 +60,14 @@
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<a <a
href="/context/documents" 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; Alle Dokumente &rarr;
</a> </a>
<button <button
type="button" type="button"
onclick={handleCreateDocument} 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> <span aria-hidden="true">+</span>
Neues Dokument Neues Dokument
@ -82,30 +82,30 @@
{#snippet listHeader()} {#snippet listHeader()}
{#if pinnedSpaces.length > 0} {#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)} {#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"> <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-white/80">{space.name}</p> <p class="text-sm font-medium text-foreground">{space.name}</p>
{#if space.description} {#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} {/if}
</div> </div>
{/each} {/each}
{/if} {/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}
{#snippet item(doc)} {#snippet item(doc)}
<a <a
href="/context/documents/{doc.id}" 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> <span class="text-sm">{@html typeIcons[doc.type] ?? '&#128196;'}</span>
<div class="min-w-0 flex-1"> <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> </div>
{#if doc.pinned} {#if doc.pinned}
<span class="text-xs text-white/30">&#128204;</span> <span class="text-xs text-muted-foreground/70">&#128204;</span>
{/if} {/if}
</a> </a>
{/snippet} {/snippet}

View file

@ -146,8 +146,8 @@
{#snippet toolbar()} {#snippet toolbar()}
<!-- Calorie progress --> <!-- Calorie progress -->
<div class="text-center"> <div class="text-center">
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p> <p class="text-2xl font-light text-foreground">{Math.round(totalCalories)}</p>
<p class="text-xs text-white/40"> <p class="text-xs text-muted-foreground">
{#if goal} {#if goal}
von {goal.dailyCalories} kcal von {goal.dailyCalories} kcal
{:else} {:else}
@ -155,12 +155,10 @@
{/if} {/if}
</p> </p>
{#if goal} {#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 <div
class="h-full rounded-full transition-all {calorieProgress >= 100 class="h-full rounded-full {calorieProgress >= 100 ? 'bg-success' : 'bg-primary'}"
? 'bg-green-400' style="width: {calorieProgress}%; transition: width 0.3s ease;"
: 'bg-blue-400'}"
style="width: {calorieProgress}%"
></div> ></div>
</div> </div>
{/if} {/if}
@ -174,7 +172,7 @@
onkeydown={onTextKeydown} onkeydown={onTextKeydown}
placeholder="Was hast du gegessen?" placeholder="Was hast du gegessen?"
disabled={quickSaving} 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 <button
type="button" type="button"
@ -182,7 +180,7 @@
disabled={!quickText.trim() || quickSaving} disabled={!quickText.trim() || quickSaving}
aria-label="Mahlzeit speichern" aria-label="Mahlzeit speichern"
title="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 ? '…' : '↵'} {quickSaving ? '…' : '↵'}
</button> </button>
@ -196,25 +194,25 @@
disabled={photoUploading} disabled={photoUploading}
aria-label="Foto hinzufuegen" aria-label="Foto hinzufuegen"
title="Foto" 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 ? '...' : '📷'} {photoUploading ? '...' : '📷'}
</button> </button>
{#if showPhotoMenu} {#if showPhotoMenu}
<div <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 <button
type="button" type="button"
onclick={openCamera} 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 <span>📸</span> Kamera
</button> </button>
<button <button
type="button" type="button"
onclick={openGallery} 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 <span>🖼️</span> Mediathek
</button> </button>
@ -250,19 +248,19 @@
{#snippet item(meal)} {#snippet item(meal)}
<a <a
href="/food/{meal.id}" 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="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <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 >{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
> >
{#if meal.inputType === 'photo'} {#if meal.inputType === 'photo'}
<span class="text-xs text-white/40">📷</span> <span class="text-xs text-muted-foreground/80">📷</span>
{/if} {/if}
</div> </div>
<p class="truncate text-sm text-white/70">{meal.description}</p> <p class="truncate text-sm text-foreground/90">{meal.description}</p>
</div> </div>
{#if meal.photoThumbnailUrl || meal.photoUrl} {#if meal.photoThumbnailUrl || meal.photoUrl}
<img <img
@ -273,7 +271,7 @@
/> />
{/if} {/if}
{#if meal.nutrition} {#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 >{Math.round(meal.nutrition.calories)} kcal</span
> >
{/if} {/if}

View file

@ -33,7 +33,7 @@
} }
function gradientStyle(colors: string[]): string { 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]}`; if (colors.length === 1) return `background: ${colors[0]}`;
return `background: linear-gradient(135deg, ${colors.join(', ')})`; return `background: linear-gradient(135deg, ${colors.join(', ')})`;
} }
@ -178,7 +178,7 @@
{#if newColors.length > 1} {#if newColors.length > 1}
<button <button
type="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 onclick={() => removeColor(i)}>x</button
> >
{/if} {/if}
@ -206,7 +206,7 @@
<!-- Save --> <!-- Save -->
<button <button
type="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} disabled={!newName.trim() || newColors.length === 0}
onclick={handleCreate} onclick={handleCreate}
> >
@ -220,7 +220,8 @@
<button <button
onclick={() => (fullscreenMood = mood)} onclick={() => (fullscreenMood = mood)}
oncontextmenu={(e) => ctxMenu.open(e, 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]}" style="--mood-color: {mood.colors[0]}"
> >
<div <div
@ -252,6 +253,13 @@
/> />
<style> <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 { .anim-gradient {
animation: gradient-shift 8s ease infinite; animation: gradient-shift 8s ease infinite;
} }

View file

@ -261,7 +261,7 @@
{/snippet} {/snippet}
{#snippet listHeader()} {#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}
{#snippet item(song)} {#snippet item(song)}
@ -272,18 +272,18 @@
_siblingIds: recentlyPlayed.map((s) => s.id), _siblingIds: recentlyPlayed.map((s) => s.id),
_siblingKey: 'songId', _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 <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; &#9835;
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{song.title}</p> <p class="truncate text-sm text-foreground">{song.title}</p>
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p> <p class="truncate text-xs text-muted-foreground">{song.artist ?? 'Unbekannt'}</p>
</div> </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> </button>
{/snippet} {/snippet}
</BaseListView> </BaseListView>

View file

@ -55,17 +55,20 @@
{#snippet listHeader()} {#snippet listHeader()}
{#if rootFolders.length > 0} {#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)} {#each rootFolders as folder (folder.id)}
<div <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
<span class="truncate text-sm text-white/70">{folder.name}</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> </div>
{/each} {/each}
{/if} {/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}
{#snippet item(file)} {#snippet item(file)}
@ -76,11 +79,11 @@
_siblingIds: recentFiles.map((f) => f.id), _siblingIds: recentFiles.map((f) => f.id),
_siblingKey: 'fileId', _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="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="min-w-0 flex-1 truncate text-sm text-foreground/90">{file.name}</span>
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span> <span class="shrink-0 text-xs text-muted-foreground/70">{formatSize(file.size)}</span>
</button> </button>
{/snippet} {/snippet}
</BaseListView> </BaseListView>

View file

@ -21,6 +21,7 @@
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs", "validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
"validate:turbo": "node scripts/validate-no-recursive-turbo.mjs", "validate:turbo": "node scripts/validate-no-recursive-turbo.mjs",
"validate:pg-schema": "node scripts/validate-pg-schema-isolation.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", "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": "node scripts/audit-crypto-registry.mjs",
"check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed", "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();