#!/usr/bin/env bash # The theme catalog is the one source of truth for every shipped palette, and # three consumers read it three ways: ThemeCatalog.qml with FileView, the # render pipeline with jq, and this contract with Node. What breaks silently: # # - a theme missing a palette key renders as QML's "undefined" black # - moon/day drifting from the shell's pre-theme literals repaints every # machine that never chose a theme # - ThemeCatalog.qml's embedded fallback drifting from the catalog means the # shell renders differently for the instant before the file loads # # Nothing at runtime cross-checks any of that. This does, statically. set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" shell_dir="$repo_dir/config/dot/quickshell" catalog="$shell_dir/config/themes.json" catalog_qml="$shell_dir/services/ThemeCatalog.qml" model="$shell_dir/services/ThemeProfileModel.js" fail() { printf 'theme catalog contract: %s\n' "$1" >&2 exit 1 } [ -f "$catalog" ] || fail "themes.json is missing" [ -f "$catalog_qml" ] || fail "ThemeCatalog.qml is missing" node - "$catalog" "$model" "$catalog_qml" <<'EOF' const fs = require("fs"); const [catalogPath, modelPath, qmlPath] = process.argv.slice(2); const model = require(modelPath); const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")); const fail = message => { console.error("theme catalog contract: " + message); process.exit(1); }; // ── Shape ──────────────────────────────────────────────────────────────── const themes = catalog.themes ?? []; if (themes.length !== 10) fail(`expected 10 shipped themes, found ${themes.length}`); const ids = new Set(); for (const theme of themes) { if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(theme.id ?? "")) fail(`bad theme id: ${theme.id}`); if (ids.has(theme.id)) fail(`duplicate theme id: ${theme.id}`); ids.add(theme.id); if (theme.scheme !== "dark" && theme.scheme !== "light") fail(`${theme.id}: bad scheme`); // The model's validators are the authority on key sets and hex shape — // if they reject a shipped theme, the shell would fall back silently. if (!model.normalizePalette(theme.palette)) fail(`${theme.id}: palette rejected by ThemeProfileModel.normalizePalette`); if (!model.normalizeAnsi(theme.ansi)) fail(`${theme.id}: ansi rejected by ThemeProfileModel.normalizeAnsi`); for (const color of [theme.accent, theme.secondary]) if (!/^#[0-9a-f]{6}$/.test(color ?? "")) fail(`${theme.id}: bad accent pair`); } if (!ids.has(catalog.defaultDark) || !ids.has(catalog.defaultLight)) fail("defaultDark/defaultLight name unknown themes"); const darkCount = themes.filter(theme => theme.scheme === "dark").length; if (darkCount !== 6 || themes.length - darkCount !== 4) fail(`expected 6 dark and 4 light themes, found ${darkCount}/${themes.length - darkCount}`); // ── moon and day are the shell's pre-theme literals ────────────────────── // These exact values were Theme.qml's ternaries before themes existed; a // machine that never chose a theme must keep rendering byte-identically. const pinned = { moon: { bg: "#222436", bgDark: "#1e2030", bgHighlight: "#2f334d", bgPanel: "#2e2f3d", bgPopover: "#21212f", fg: "#c8d3f5", fgDim: "#828bb8", fgMuted: "#636da6", gutter: "#3b4261", accentAlt: "#65bcff", cyan: "#86e1fc", teal: "#4fd6be", green: "#c3e88d", yellow: "#ffc777", orange: "#ff966c", red: "#ff757f", redDeep: "#c53b53", magenta: "#c099ff", pink: "#fca7ea", accent: "#82aaff", secondary: "#b172b0" }, day: { bg: "#e1e2e7", bgDark: "#d3d5de", bgHighlight: "#c4c8da", bgPanel: "#d9dae3", bgPopover: "#eaeaee", fg: "#3760bf", fgDim: "#6172b0", fgMuted: "#848cb5", gutter: "#a8aecb", accentAlt: "#007197", cyan: "#007197", teal: "#118c74", green: "#587539", yellow: "#8c6c3e", orange: "#b15c00", red: "#f52a65", redDeep: "#c64343", magenta: "#9854f1", pink: "#d20065", accent: "#2e7de9", secondary: "#9854f1" } }; for (const [id, expected] of Object.entries(pinned)) { const theme = themes.find(entry => entry.id === id); if (!theme) fail(`shipped theme ${id} is missing`); for (const [key, value] of Object.entries(expected)) { const actual = key === "accent" || key === "secondary" ? theme[key] : theme.palette[key]; if (actual !== value) fail(`${id}.${key} drifted: ${actual} (expected ${value})`); } } // ── The QML fallback carries moon and day byte-identically ─────────────── // ThemeCatalog.qml embeds the two defaults for the instant before FileView // loads; every pinned hex above must appear in the QML, and every hex in the // fallback block must exist somewhere in the catalog's moon/day records. const qml = fs.readFileSync(qmlPath, "utf8"); for (const [id, expected] of Object.entries(pinned)) for (const [key, value] of Object.entries(expected)) if (!qml.includes(value)) fail(`ThemeCatalog.qml fallback is missing ${id}.${key} = ${value}`); if (!/fallbackThemes/.test(qml)) fail("ThemeCatalog.qml no longer declares fallbackThemes"); // ── The model and the catalog agree on the token sets ──────────────────── const paletteKeys = Object.keys(themes[0].palette).sort(); if (JSON.stringify(paletteKeys) !== JSON.stringify([...model.PALETTE_KEYS].sort())) fail("themes.json palette keys diverge from ThemeProfileModel.PALETTE_KEYS"); const ansiKeys = Object.keys(themes[0].ansi).sort(); if (JSON.stringify(ansiKeys) !== JSON.stringify([...model.ANSI_KEYS].sort())) fail("themes.json ansi keys diverge from ThemeProfileModel.ANSI_KEYS"); console.log(`theme catalog contract: ok (${themes.length} themes, ${model.PALETTE_KEYS.length} palette keys)`); EOF # Theme.qml must read every palette token from the resolver, never a literal # ternary — a reintroduced literal would silently stop following themes. if grep -E 'readonly property color (bg|fg|gutter|cyan|teal|green|yellow|orange|red|magenta|pink)[A-Za-z]*:.*root\.dark \?' \ "$shell_dir/config/Theme.qml" >/dev/null; then fail "Theme.qml has regrown a hardcoded palette ternary" fi grep -q 'ThemeProfiles.activePalette' "$shell_dir/config/Theme.qml" \ || fail "Theme.qml no longer reads ThemeProfiles.activePalette" printf 'theme catalog contract: ok\n'