diff --git a/lint-staged.config.js b/lint-staged.config.js index 09abe24ee..461e16047 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -4,11 +4,18 @@ export default { 'prettier --config .prettierrc.json --write', ], '*.{json,md,svelte,astro}': ['prettier --config .prettierrc.json --write'], - // Theme-token audit whenever styles-bearing files change. Runs on the - // whole tree (ignores staged filenames), scans ~3k files in <1s, and - // fails if any file re-introduces bare --muted / --theme-* references - // instead of Mana's canonical --color-* tokens. - '*.{svelte,css}': () => 'node scripts/audit-theme-tokens.mjs', + // Theme-variable + utility audit whenever styles-bearing files change. + // Runs on the whole tree (ignores staged filenames), scans ~3k files + // in <1s, and fails if any file re-introduces: + // - bare `--muted` / `--theme-*` CSS variables instead of Mana's + // canonical `--color-*` tokens (validate-theme-variables.mjs), or + // - raw white-alpha / neutral-palette Tailwind utilities instead of + // theme-token utilities (validate-theme-utilities.mjs). + '*.{svelte,css}': () => [ + 'node scripts/validate-theme-variables.mjs', + 'node scripts/validate-theme-utilities.mjs', + 'node scripts/validate-theme-parity.mjs', + ], // Validate the tunnel config locally so a malformed ingress map can // never reach main. The validator runs entirely in node (no // cloudflared CLI dependency on the dev box) and catches the same diff --git a/package.json b/package.json index e233cd5ed..e24f56557 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ "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 validate:theme-tokens && pnpm run check:crypto", + "validate:theme-variables": "node scripts/validate-theme-variables.mjs", + "validate:theme-utilities": "node scripts/validate-theme-utilities.mjs", + "validate:theme-parity": "node scripts/validate-theme-parity.mjs", + "validate:all": "pnpm run validate:turbo && pnpm run validate:pg-schema && pnpm run validate:theme-variables && pnpm run validate:theme-utilities && pnpm run validate:theme-parity && pnpm run check:crypto", "check:crypto": "node scripts/audit-crypto-registry.mjs", "check:crypto:seed": "node scripts/audit-crypto-registry.mjs --seed", "audit:deps": "node scripts/audit-workspace-deps.mjs", @@ -30,7 +32,6 @@ "audit:coupling": "node scripts/audit-module-coupling.mjs", "audit:complexity": "node scripts/audit-complexity.mjs", "audit:map": "node scripts/build-complexity-map.mjs", - "audit:theme-tokens": "node scripts/audit-theme-tokens.mjs", "generate:dockerfiles": "node scripts/generate-dockerfiles.mjs", "setup:env": "node scripts/generate-env.mjs", "setup:secrets": "node scripts/setup-secrets.mjs", diff --git a/packages/shared-tailwind/brand-literals.md b/packages/shared-tailwind/brand-literals.md new file mode 100644 index 000000000..f56617466 --- /dev/null +++ b/packages/shared-tailwind/brand-literals.md @@ -0,0 +1,202 @@ +# Brand-Literal Color Register + +This document lists every module that intentionally uses literal color +values (`#RRGGBB`, `rgb()`, `rgba()`, `hsl()` without `var(--color-*)`) +**instead of** theme tokens — and the reason why. + +It exists because the theme-token validators (`validate-theme-utilities`, +`validate-theme-variables`) would otherwise flag these as drift. They are +not drift: the color _is the point_. + +## The rule + +Colors in Mana fall into three buckets: + +1. **Theme tokens** (`bg-muted`, `text-foreground`, `hsl(var(--color-primary))`) + — surfaces, text, borders. Must track the active theme variant. + +2. **Semantic tokens** (`--color-success`, `--color-warning`, `--color-error`) + — feedback states. Also theme-aware. + +3. **Brand literals** (documented here) — domain-semantic colors that + intentionally _do not_ track the theme. A period tracker's pink is + pink in every theme. + +If you're adding a literal color that isn't on this list, think: "does +the domain require this exact hue, or am I reaching for a nearby theme +token?" Default to the token. Add to this register only when the domain +genuinely demands the literal. + +## Per-module inventory + +### `period` — menstrual cycle tracking + +| Purpose | Colors | Why literal | +| ------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| Flow severity ramp | `#fda4af`, `#fb7185`, `#e11d48`, `#9f1239` | Spotting → light → medium → heavy. Literal red ramp is the domain. | +| Cycle phases | `#e11d48`, `#f59e0b`, `#22c55e`, `#8b5cf6` | Menstruation/follicular/ovulation/luteal — standardised colours in reproductive-health UX. | +| Module accent | `#ec4899` + `rgba(236,72,153, …)` | Pink is the module's identity; users recognise it across contexts. | + +Files: `lib/modules/period/types.ts`, `lib/modules/period/ListView.svelte` + +### `citycorners` — city discovery + +| Purpose | Colors | Why literal | +| ---------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Location category pins | 11 colours from `CATEGORY_COLORS` | Sight/restaurant/shop/museum/cafe/bar/park/beach/hotel/venue/viewpoint — map-convention colours that readers recognise at a glance. | + +Files: `lib/modules/citycorners/types.ts` + +### `who` — historical-persona guessing game + +| Purpose | Colors | Why literal | +| ------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Deck accents | `#a855f7` historical, `#ec4899` women, `#f59e0b` antiquity, `#0ea5e9` inventors | Per-deck identity; colour primes the player about era/theme. | + +Files: `lib/modules/who/ListView.svelte`, `lib/modules/who/views/PlayView.svelte` + +### `firsts` — life-experience log + +| Purpose | Colors | Why literal | +| --------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- | +| Experience categories | 11 colours (`#f97316` culinary, `#ef4444` adventure, …) | Domain-semantic tags the user picks — must look identical every visit. | + +Files: `lib/modules/firsts/types.ts` + +### `habits` — habit tracker + +| Purpose | Colors | Why literal | +| -------------------------- | ------------------------------------ | ------------------------------------------------------------------ | +| User-pickable habit colour | 14-colour palette, full hue spectrum | User-owned choice; changing under theme would feel like data loss. | + +Files: `lib/modules/habits/types.ts` + +### `finance` — expense tracking + +| Purpose | Colors | Why literal | +| -------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------- | +| Default expense categories | 8 colours (`#f97316` food, `#3b82f6` transport, …) | Category-colour conventions (health=green, travel=blue) stable across themes. | +| Custom-category palette | 13 curated colours | User-pickable palette for personal categories. | + +Files: `lib/modules/finance/types.ts` + +### `times` — time tracking + +| Purpose | Colors | Why literal | +| --------------- | ------------------------------------ | -------------------------------------------------------------- | +| Project colours | 16-colour palette (`PROJECT_COLORS`) | Full hue spectrum for visual distinction across many projects. | + +Files: `lib/modules/times/types.ts` + +### `journal` / `mood` / `dreams` — emotion logging + +| Purpose | Colors | Why literal | +| -------------------------- | --------------------------------------------------- | ------------------------------------------------------------ | +| Emotion valence | Green (positive) → amber → red (negative) spectrums | Domain convention: emotion-colour psychology is the feature. | +| User-pickable mood colours | 16 fixed hues | User-selected identity per mood. | + +Files: `lib/modules/journal/types.ts`, `lib/modules/mood/types.ts`, +`lib/modules/dreams/types.ts` + +### `recipes` — difficulty indicator + +| Purpose | Colors | Why literal | +| ---------- | ------------------------------------------------ | ------------------------------------------------------- | +| Difficulty | `#22c55e` easy, `#f59e0b` medium, `#ef4444` hard | Traffic-light pattern — universal, not theme-dependent. | + +Files: `lib/modules/recipes/types.ts` + +### `notes` — highlight colour palette + +| Purpose | Colors | Why literal | +| -------------- | -------------------------------- | ------------------------------- | +| Note highlight | 10-colour curated palette + null | User-pickable; stable per note. | + +Files: `lib/modules/notes/types.ts` + +### `drink` — beverage categories + +| Purpose | Colors | Why literal | +| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | +| Drink-type palette | 13 colours (`#92400e` coffee, `#881337` wine, `#3b82f6` water, …) | Semantic mapping to beverage (brown coffee, red wine). | + +Files: `lib/modules/drink/types.ts` + +### `sleep` — quality ratings + +| Purpose | Colors | Why literal | +| ------------------- | --------------------------------- | -------------------------------- | +| Module accent | `#6366f1` indigo | Module identity. | +| Sleep-quality scale | `#22c55e` / `#f59e0b` / `#ef4444` | Traffic-light quality indicator. | + +Files: `lib/modules/sleep/ListView.svelte`, `lib/modules/sleep/components/*.svelte` + +### `spiral` / `quotes` — spiral canvas visualisation + +| Purpose | Colors | Why literal | +| --------------------- | --------------------------------- | ---------------------------------------------------------- | +| Canvas background | `#1a1a1a` (near-black) | Canvas needs a fixed contrast surface regardless of theme. | +| Golden accent lines | `#fbbf24` | Intentional warm-gold against dark canvas. | +| Violet overlay + glow | `#8b5cf6`, `rgba(99,102,241,0.1)` | Module identity: indigo → violet ramp. | + +Files: `lib/modules/spiral/ListView.svelte`, +`lib/modules/spiral/components/SpiralCanvas.svelte`, +`lib/modules/quotes/components/SpiralCanvas.svelte` + +### `photos` — photo viewer + +| Purpose | Colors | Why literal | +| ---------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Near-black backdrop gradient | `rgba(0,0,0,0.8) → transparent` | Photo viewers always use black backdrop — theme variants would leak colour onto the photo. | +| Upload status overlays | `rgba(0,0,0,0.4)`, `rgba(34,197,94,0.5)`, `rgba(239,68,68,0.5)` | State feedback against arbitrary image content. | + +Files: `lib/modules/photos/ListView.svelte`, +`lib/modules/photos/components/albums/AlbumCard.svelte` + +### `moodlit` — ambient mood gradients + +| Purpose | Colors | Why literal | +| ---------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| User-authored gradient stops | Arbitrary user-picked hex | The whole module's value is rendering the user's colours verbatim. | +| White-alpha overlays | `bg-white/20`, `text-white`, etc. | UI chrome sits on a vivid gradient background — only white reads. Validator exempts `MoodFullscreen.svelte`, `MoodCard.svelte`, `CreateMoodDialog.svelte`. | + +Files: `lib/modules/moodlit/components/mood/*.svelte` + +### `calc` — retro calculator skins + +| Skin | Colors | Why literal | +| ----------- | ------------------------------------------ | ----------------------------------------------------- | +| TI-84 Plus | `#2a4a3a`, `#aaffaa`, `#88cc88`, `#3366aa` | 1990s TI LCD green — the authenticity is the feature. | +| HP-35 | `#ff3333`, `#ff2200`, `#c63030` | 1971 HP LED red display. | +| Casio fx-82 | `#b8c8a0`, `#3a4a2a`, `#1a2a0a` | 1980s Casio LCD beige-green. | + +Files: `lib/modules/calc/components/{TI84Skin,HP35Skin,CasioSkin}.svelte` + +### `presi` — rehearsal blocks + +| Purpose | Colors | Why literal | +| --------------------------- | -------------- | ---------------------------------------------------------- | +| Rehearsal time-block accent | `#84cc16` lime | Distinguishes practice sessions from real calendar events. | + +Files: `lib/modules/presi/stores/decks.svelte.ts` + +### `--color-branch-*` — skilltree accents (actually theme-aware) + +`skilltree` uses per-branch accent colours defined as _theme-agnostic CSS +variables_ (`--color-branch-intellect`, `--color-branch-body`, …) in +`themes.css`. These are declared once at `:root` and intentionally do not +have dark/variant overrides — they look identical regardless of theme. +Consumers must still wrap with `hsl(var(--color-branch-X))`. + +The parity validator exempts them via the `THEME_AGNOSTIC` allowlist. + +## Summary + +- **16 modules** hold brand-literal colours. +- **~70 unique hex values** across them. +- The two validators (`validate-theme-utilities`, `validate-theme-variables`) + already ignore these files / these patterns where necessary. If a new + literal trips the validator, either: + 1. Add it to this register and (if it's a white-alpha overlay case) + to `BRAND_OVERLAY_FILES` in `validate-theme-utilities.mjs`, or + 2. Migrate the literal to a theme token — usually the right answer. diff --git a/packages/shared-tailwind/src/themes.css b/packages/shared-tailwind/src/themes.css index f15f3f6a0..bf25acc8a 100644 --- a/packages/shared-tailwind/src/themes.css +++ b/packages/shared-tailwind/src/themes.css @@ -35,12 +35,15 @@ * ✅ color: hsl(var(--color-foreground)); * ✅ background: hsl(var(--color-primary) / 0.12); * - * 4. Brand-literal colors (period pink, observatory cosmic scenes, the - * automations/spiral indigo→violet ramp, sport/category palettes, the - * photo viewer's near-black backdrop, etc.) deliberately stay as - * literal hex/rgba/hsl. They are NOT theme intent — they encode brand - * or domain semantics that must look the same under every theme - * variant. Don't migrate them to tokens. + * 4. Brand-literal colors (period pink, photo-viewer near-black backdrop, + * skilltree branch accents, spiral indigo→violet ramp, calc retro + * skins, etc.) deliberately stay as literal hex/rgba/hsl. They are + * NOT theme intent — they encode brand or domain semantics that must + * look the same under every theme variant. Don't migrate them to + * tokens. The complete inventory with per-module rationale lives in + * packages/shared-tailwind/brand-literals.md — add to that register + * when introducing a new literal, or prefer a theme token if the + * colour is not domain-semantic. * * 5. Token names are stable. There is intentionally NO `--color-info`, * `--color-text`, `--color-destructive`, `--color-surface`, or diff --git a/scripts/validate-theme-parity.mjs b/scripts/validate-theme-parity.mjs new file mode 100644 index 000000000..8b6513d9d --- /dev/null +++ b/scripts/validate-theme-parity.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Validate that every --color-* token defined for the theme is present in + * every variant block. A token defined only in :root (but missing from + * .dark, or from [data-theme="ocean"]) silently falls back to `inherit` + * or `undefined` under that variant, producing invisible text or + * miscoloured surfaces in ways that are easy to miss during dev. + * + * This is the parity invariant: if one variant has `--color-X`, every + * variant MUST have `--color-X`. + * + * Scope: the "canonical" token set is derived from the default :root + * block (after the base :root block for domain accents). Each + * theme-variant block is compared to it. + * + * Exclusions: `--color-branch-*` and `--color-mana` are declared once + * (intentionally theme-agnostic brand accents, see themes.css §Domain + * Accent Colors) and do not need per-variant definitions. + * + * Usage: + * node scripts/validate-theme-parity.mjs + */ + +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 THEMES_CSS = join(REPO_ROOT, 'packages/shared-tailwind/src/themes.css'); + +/** Tokens defined once at :root that do NOT participate in parity. */ +const THEME_AGNOSTIC = /^--color-(?:branch-|mana$)/; + +/** + * Parse themes.css into selector → Set. A block starts at a + * line matching a selector followed by `{` and ends at the matching `}`. + * The base `@theme` / `@theme inline` blocks are skipped — they define + * Tailwind's utility generator, not the per-variant surfaces. + */ +function parseBlocks(src) { + const blocks = new Map(); + const lines = src.split('\n'); + let currentSelector = null; + let depth = 0; + let inBase = false; // true while inside @theme {} / @theme inline {} + + for (const raw of lines) { + const line = raw.trim(); + + // Track @theme blocks so we skip their contents. + if (/^@theme\b/.test(line)) { + inBase = true; + if (line.includes('{')) depth++; + continue; + } + + if (inBase) { + if (line.includes('{')) depth++; + if (line.includes('}')) { + depth--; + if (depth === 0) inBase = false; + } + continue; + } + + // Look for block open: `selector {` or `selector,` continued. + if (!currentSelector) { + const selectorMatch = raw.match( + /^(:root(?:\.dark)?|\.dark|\[data-theme=['"](?:[a-z-]+)['"]\](?:\.dark)?|\.dark\[data-theme=['"](?:[a-z-]+)['"]\])\s*(?:,|\{)/ + ); + if (selectorMatch) { + // Normalize variant key so `.dark, :root.dark` both fold into "dark". + const sel = selectorMatch[1]; + currentSelector = normalizeSelector(sel); + if (line.includes('{')) depth = 1; + continue; + } + // Comma-chained selector continuation: if a previous line had + // `selector,`, the next line is another selector that shares the + // same block. + continue; + } + + // Inside a tracked block. + if (line.includes('{')) depth++; + if (line.includes('}')) { + depth--; + if (depth === 0) currentSelector = null; + continue; + } + + // Token definition: `--color-X: value;` + const tokenMatch = line.match(/^(--color-[a-z0-9-]+)\s*:/); + if (tokenMatch && currentSelector) { + if (!blocks.has(currentSelector)) blocks.set(currentSelector, new Set()); + blocks.get(currentSelector).add(tokenMatch[1]); + } + } + + return blocks; +} + +function normalizeSelector(sel) { + // `:root.dark` and `.dark` collapse to the same variant key. + if (sel === '.dark' || sel === ':root.dark') return 'dark'; + const variantMatch = sel.match(/\[data-theme=['"]([a-z-]+)['"]\]/); + if (variantMatch) { + return sel.includes('.dark') ? `${variantMatch[1]}.dark` : variantMatch[1]; + } + return sel; +} + +/** + * Collapse blocks that share a canonical identity (e.g. `.dark` and + * `:root.dark` both represent "dark"; each per-variant block and its + * `.dark` counterpart stay separate). + */ +function mergeBlocks(blocks) { + const merged = new Map(); + for (const [sel, tokens] of blocks) { + const existing = merged.get(sel) ?? new Set(); + for (const t of tokens) existing.add(t); + merged.set(sel, existing); + } + return merged; +} + +function validate() { + const src = readFileSync(THEMES_CSS, 'utf8'); + const blocks = mergeBlocks(parseBlocks(src)); + + if (!blocks.has(':root')) { + console.error('✗ Could not find :root block in themes.css — parser broken?'); + process.exit(2); + } + + // Canonical token set = :root tokens, minus theme-agnostic ones. + const canonical = new Set([...blocks.get(':root')].filter((t) => !THEME_AGNOSTIC.test(t))); + + const violations = []; + + for (const [selector, tokens] of blocks) { + if (selector === ':root') continue; + + const filtered = new Set([...tokens].filter((t) => !THEME_AGNOSTIC.test(t))); + + const missing = [...canonical].filter((t) => !filtered.has(t)); + const extra = [...filtered].filter((t) => !canonical.has(t)); + + if (missing.length > 0) violations.push({ selector, kind: 'missing', tokens: missing }); + if (extra.length > 0) violations.push({ selector, kind: 'extra', tokens: extra }); + } + + if (violations.length > 0) { + console.error(`\n✗ Theme parity check FAILED (${violations.length} block-level issue(s)):\n`); + for (const v of violations) { + const marker = v.kind === 'missing' ? 'missing from' : 'extra in'; + console.error(` ${v.tokens.length} token(s) ${marker} [${v.selector}]:`); + for (const t of v.tokens.slice(0, 10)) console.error(` - ${t}`); + if (v.tokens.length > 10) console.error(` … +${v.tokens.length - 10} more`); + } + console.error( + `\nEvery --color-* token must be defined in :root AND .dark AND every\n` + + `[data-theme="..."] block. A missing token silently falls back to\n` + + `inherit (or undefined), which produces invisible text in the affected\n` + + `variant. Theme-agnostic accents (--color-branch-*, --color-mana) are\n` + + `exempt. See packages/shared-tailwind/src/themes.css.\n` + ); + process.exit(1); + } + + const tokenCount = canonical.size; + const variantCount = blocks.size - 1; // minus :root + console.log(`✓ Theme parity: ${tokenCount} tokens × ${variantCount} variants — all aligned.`); +} + +validate(); diff --git a/scripts/validate-theme-tokens.mjs b/scripts/validate-theme-utilities.mjs similarity index 100% rename from scripts/validate-theme-tokens.mjs rename to scripts/validate-theme-utilities.mjs diff --git a/scripts/audit-theme-tokens.mjs b/scripts/validate-theme-variables.mjs similarity index 95% rename from scripts/audit-theme-tokens.mjs rename to scripts/validate-theme-variables.mjs index 175e257dc..ee7906563 100644 --- a/scripts/audit-theme-tokens.mjs +++ b/scripts/validate-theme-variables.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Audit Theme Tokens + * Validate theme CSS variable names. * * The Mana theme system standardizes on `--color-*` CSS custom properties * (defined in `packages/shared-tailwind/src/themes.css`). Earlier, components @@ -10,12 +10,15 @@ * references silently fell back to nothing (or to literal fallbacks) and * stopped tracking the active theme variant. * - * This audit greps Svelte/CSS/TS source files for those legacy patterns + * This check greps Svelte/CSS/TS source files for those legacy patterns * and fails if any remain. Run in CI and lint-staged so the drift can't * sneak back in. * + * Companion check: `validate-theme-utilities.mjs` enforces theme-token + * Tailwind classes (e.g. `text-foreground` instead of `text-white/80`). + * * Usage: - * node scripts/audit-theme-tokens.mjs + * node scripts/validate-theme-variables.mjs */ import { readdirSync, statSync, readFileSync } from 'fs';