#!/usr/bin/env bash # The theme record and the selection flow. # # ThemeProfileModel.js is the whole of the record's grammar: what a theme is # made of, which parts are optional, what happens to a stored record that is # half wrong, and which curated accent an arbitrary colour is nearest to. All # of it is pure and runs under Node, so it is checked directly rather than # through the shell. # # Two things here are easy to break silently and expensive when broken: # # - `shippedProfiles()` with no argument is the pre-catalog fallback. The # shell renders with it for the instant before config/themes.json loads, # and forever on a machine where that file is missing. It must stay the # three built-in records, unchanged, even though every runtime caller now # passes the ten-theme catalog. # - A stored custom with one bad palette key must lose the FIELD, not the # profile. Dropping the profile would delete somebody's saved theme # because a single hex went wrong. # # The live half pins the selection flow: a light/dark flip lands on the theme # you last chose on that side, never a forced reset to Moon and Day. set -euo pipefail repo_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" catalog="$repo_dir/config/dot/quickshell/config/themes.json" node - "$model" "$catalog" <<'JS' const assert = require('node:assert/strict') const fs = require('node:fs') const model = require(process.argv[2]) const catalog = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) // ThemeCatalog.normalizeTheme() stamps `shipped: true` on every record before // handing the list to the model, so the fixture has to as well -- without it // the model would treat catalog themes as editable customs. const shippedCatalog = catalog.themes.map(theme => ({ ...theme, shipped: true })) // ── Back-compat: no argument is still the three built-in fallbacks ────────── const shipped = model.shippedProfiles() assert.deepEqual(shipped, [ { id: 'moon', name: 'Moon', scheme: 'dark', accent: '#82aaff', secondary: '#b172b0', shipped: true }, { id: 'moon-rose', name: 'Moon Rose', scheme: 'dark', accent: '#ff757f', secondary: '#c099ff', shipped: true }, { id: 'day', name: 'Day', scheme: 'light', accent: '#2e7de9', secondary: '#9854f1', shipped: true } ]) // ── The catalog's records survive the same copy, palettes included ────────── const full = model.shippedProfiles(shippedCatalog) assert.equal(full.length, 10) assert.deepEqual(full.map(p => p.id), shippedCatalog.map(t => t.id)) for (const profile of full) { assert.ok(model.normalizePalette(profile.palette), `${profile.id} lost its palette`) assert.ok(model.normalizeAnsi(profile.ansi), `${profile.id} lost its ansi block`) // Shipped themes carry no effects snapshot, so applying one leaves the // user's blur, shadows and motion exactly where they were. assert.equal(profile.effects, undefined) assert.equal(profile.shipped, true) } const moonPalette = shippedCatalog.find(t => t.id === 'moon').palette const moonAnsi = shippedCatalog.find(t => t.id === 'moon').ansi // ── Optional fields survive a stored record ──────────────────────────────── const storedInput = { id: 'custom-full', name: 'Full', scheme: 'dark', accent: '#86E1FC', secondary: '#82AAFF', palette: moonPalette, ansi: moonAnsi, effects: { blurEnabled: true, blurSize: 40, shadowSharp: 'yes', glowRange: 12 } } const stored = model.validCustomProfiles([storedInput], shippedCatalog)[0] assert.equal(stored.accent, '#86e1fc') assert.deepEqual(stored.palette, model.normalizePalette(moonPalette)) assert.deepEqual(stored.ansi, model.normalizeAnsi(moonAnsi)) // Effects are per-key: out of range clamps, wrong-typed drops, the rest lands. assert.deepEqual(stored.effects, { blurEnabled: true, blurSize: 20, glowRange: 12 }) // ── An invalid palette drops the FIELD, never the profile ─────────────────── const partial = { ...storedInput, id: 'custom-partial', name: 'Partial', palette: { bg: '#222436' }, ansi: 'not an object', effects: { blurSize: 'wide' } } const kept = model.validCustomProfiles([partial], shippedCatalog) assert.equal(kept.length, 1) assert.equal(kept[0].id, 'custom-partial') assert.equal(kept[0].palette, undefined) assert.equal(kept[0].ansi, undefined) assert.equal(kept[0].effects, undefined) assert.equal(kept[0].accent, '#86e1fc') // ── The shipped list a caller passes owns the id and name space ───────────── const clash = model.createCustomProfile([], { name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1' }, shippedCatalog) assert.equal(clash.profile.name, 'Nord 2') assert.equal(clash.profile.id, 'custom-nord-2') const free = model.createCustomProfile([], { name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1' }) assert.equal(free.profile.name, 'Nord') assert.equal(free.profile.id, 'custom-nord') // A stored custom colliding with a catalog name is RENAMED, never dropped — // upgrades that add shipped themes must not delete a user's saved theme. The // same record against the three-entry fallback keeps its name, because there // "Nord" is free. Ids stay the identity either way. const impostor = { id: 'custom-nord', name: 'Nord', scheme: 'dark', accent: '#88c0d0', secondary: '#81a1c1', shipped: false } const renamed = model.validCustomProfiles([impostor], shippedCatalog) assert.equal(renamed.length, 1) assert.equal(renamed[0].id, 'custom-nord') assert.equal(renamed[0].name, 'Nord 2') assert.equal(model.validCustomProfiles([impostor])[0].name, 'Nord') // ── createCustomProfile carries the optional fields through ───────────────── const rich = model.createCustomProfile([], { name: 'Rich', scheme: 'dark', accent: '#86e1fc', secondary: '#82aaff', palette: moonPalette, ansi: moonAnsi, effects: { animationsEnabled: false } }, shippedCatalog) assert.deepEqual(rich.profile.palette, model.normalizePalette(moonPalette)) assert.deepEqual(rich.profile.ansi, model.normalizeAnsi(moonAnsi)) assert.deepEqual(rich.profile.effects, { animationsEnabled: false }) // ── editProfile passes the fields through ─────────────────────────────────── const recolored = model.resaturatePalette(moonPalette, 1.4) const editedCustom = model.editProfile(rich.profiles, rich.profile, { palette: recolored }, shippedCatalog) assert.equal(editedCustom.profile.id, rich.profile.id) assert.deepEqual(editedCustom.profile.palette, recolored) // Untouched fields are not collateral damage of a palette edit. assert.deepEqual(editedCustom.profile.ansi, model.normalizeAnsi(moonAnsi)) assert.deepEqual(editedCustom.profile.effects, { animationsEnabled: false }) assert.equal(editedCustom.profiles.length, 1) // Forking a shipped theme carries its whole palette, so the fork looks // identical until the edit lands -- and leaves the original untouched. const nord = full.find(p => p.id === 'nord') const fork = model.editProfile([], nord, { accent: '#a3be8c', secondary: '#88c0d0' }, shippedCatalog) assert.equal(fork.profile.shipped, false) assert.equal(fork.profile.name, 'Nord custom') assert.equal(fork.profile.accent, '#a3be8c') assert.deepEqual(fork.profile.palette, model.normalizePalette(nord.palette)) assert.deepEqual(fork.profile.ansi, model.normalizeAnsi(nord.ansi)) assert.deepEqual(nord, full.find(p => p.id === 'nord')) // ── nearestCuratedName is what keeps accentName in sync ───────────────────── // Nearest by hue, per scheme, because each curated name carries a different // pair on each side. This is the function that stops GNOME's accent enum, // kitty's border and the lock screen going stale after a custom edit. for (const [accent, expected] of [ ['#cba6f7', 'orchid'], // Catppuccin mauve ['#fabd2f', 'amber'], // Gruvbox yellow ['#808080', 'slate'], // a desaturated grey is slate, not a hue guess ['#82aaff', 'blue'], // Moon ['#a7c080', 'green'], // Everforest green ['#88c0d0', 'teal'] // Nord frost ]) assert.equal(model.nearestCuratedName('dark', accent), expected, `dark ${accent}`) for (const [accent, expected] of [ ['#cba6f7', 'orchid'], ['#fabd2f', 'amber'], ['#808080', 'slate'], ['#2e7de9', 'blue'], // Day ['#a7c080', 'green'], ['#88c0d0', 'teal'] ]) assert.equal(model.nearestCuratedName('light', accent), expected, `light ${accent}`) assert.equal(model.nearestCuratedName('dark', 'not-a-colour'), 'blue') // Every shipped theme resolves to a real curated name. for (const theme of full) assert.ok(model.curatedAccents()[model.nearestCuratedName(theme.scheme, theme.accent)], `${theme.id} has no curated accent`) // ── Derivation produces palettes the validators accept ────────────────────── const derived = model.derivePalette(moonPalette, { scheme: 'dark', bg: '#101020', fg: '#e8e8f8', accent: '#86e1fc' }) assert.ok(model.normalizePalette(derived)) assert.equal(derived.bg, '#101020') assert.equal(derived.fg, '#e8e8f8') // The surfaces and text tints moved with the ground rather than staying behind. assert.notEqual(derived.bgDark, moonPalette.bgDark) assert.notEqual(derived.fgDim, moonPalette.fgDim) // Tokens the wells do not own are inherited untouched, so a small edit stays // a small edit. assert.equal(derived.green, moonPalette.green) assert.equal(model.derivePalette(null, { scheme: 'dark' }), null) const saturated = model.resaturatePalette(moonPalette, 1.5) assert.ok(model.normalizePalette(saturated)) // At zero the colour tokens go fully grey while the grounds keep most of their // tint -- backgrounds move at quarter strength so the ground stays a ground. const grey = model.resaturatePalette(moonPalette, 0) assert.ok(model.normalizePalette(grey)) const channels = hex => [hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)] for (const key of ['green', 'red', 'magenta', 'accentAlt']) { const [r, g, b] = channels(grey[key]) assert.ok(r === g && g === b, `${key} did not desaturate to grey: ${grey[key]}`) } const [bgR, bgG, bgB] = channels(grey.bg) assert.ok(!(bgR === bgG && bgG === bgB), 'the ground desaturated at full strength') // Neutral is a round trip through HSV, so it is near-identical rather than // byte-identical; what matters is that it stays a palette and stays in family. const neutral = model.resaturatePalette(moonPalette, 1) assert.ok(model.normalizePalette(neutral)) for (const key of model.PALETTE_KEYS) for (let offset = 1; offset < 7; offset += 2) assert.ok(Math.abs(parseInt(neutral[key].slice(offset, offset + 2), 16) - parseInt(moonPalette[key].slice(offset, offset + 2), 16)) <= 3, `${key} drifted at neutral saturation: ${neutral[key]} vs ${moonPalette[key]}`) assert.equal(model.resaturatePalette({ bg: '#000000' }, 1.2), null) const ansi = model.deriveAnsi(moonPalette, '#86e1fc') assert.ok(model.normalizeAnsi(ansi)) assert.equal(ansi.blue, '#86e1fc') assert.equal(ansi.white, moonPalette.fgDim) assert.equal(ansi.brightWhite, moonPalette.fg) assert.equal(model.deriveAnsi({ bg: '#000000' }, '#86e1fc'), null) assert.equal(model.mixHex('#000000', '#ffffff', 0.5), '#808080') assert.equal(model.mixHex('#000000', '#ffffff', 0), '#000000') assert.equal(model.mixHex('#000000', '#ffffff', 1), '#ffffff') // ── The unchanged core still holds ────────────────────────────────────────── const first = model.createCustomProfile([], { name: ' Ocean ', scheme: 'dark', accent: '#86E1FC', secondary: '#82AAFF' }) assert.deepEqual(first.profile, { id: 'custom-ocean', name: 'Ocean', scheme: 'dark', accent: '#86e1fc', secondary: '#82aaff', shipped: false }) const second = model.createCustomProfile(first.profiles, { name: 'ocean', scheme: 'light', accent: '#007197', secondary: '#2e7de9' }) assert.equal(second.profile.name, 'ocean 2') assert.equal(second.profile.id, 'custom-ocean-2') const bounded = model.createCustomProfile(second.profiles, { name: 'A theme name that is deliberately much longer than forty characters', scheme: 'dark', accent: '#c3e88d', secondary: '#86e1fc' }) assert.equal(bounded.profile.name.length, 40) assert.equal(model.deleteProfile(fork.profiles, 'nord', shippedCatalog).removed, false) assert.equal(model.deleteProfile(fork.profiles, fork.profile.id, shippedCatalog).removed, true) assert.deepEqual(model.profileCatalog([ first.profile, { id: 'moon', name: 'Counterfeit', scheme: 'dark', accent: '#ffffff', secondary: '#ffffff', shipped: false }, { id: 'custom-bad', name: 'Bad', scheme: 'sepia', accent: '#ffffff', secondary: '#ffffff', shipped: false } ], shippedCatalog), [...full, first.profile]) assert.equal(model.hsvToHex(0, 100, 100), '#ff0000') assert.equal(model.hsvToHex(120, 100, 100), '#00ff00') assert.equal(model.hsvToHex(240, 100, 100), '#0000ff') assert.equal(model.hsvToHex(360, 100, 100), '#ff0000') assert.equal(model.hsvToHex(0, 0, 50), '#808080') assert.deepEqual(model.hexToHsv('#ff0000'), { h: 0, s: 100, v: 100 }) assert.deepEqual(model.hexToHsv('#82aaff'), { h: 221, s: 49, v: 100 }) assert.equal(model.hexToHsv('not-a-colour'), null) assert.equal(model.curatedNameForProfile(shipped[0]), 'blue') assert.equal(model.curatedNameForProfile(shipped[1]), 'rose') assert.equal(model.matchingShippedProfile('dark', '#ff757f', '#c099ff', shippedCatalog).id, 'moon-rose') assert.equal(model.matchingShippedProfile('light', '#2e7de9', '#9854f1', shippedCatalog).id, 'day') assert.equal(model.curatedNameForProfile({ id: 'custom-unmatched', name: 'Unmatched', scheme: 'dark', accent: '#123456', secondary: '#654321', shipped: false }), '') assert.equal(model.PALETTE_KEYS.length, 19) assert.equal(model.ANSI_KEYS.length, 16) assert.equal(Object.keys(model.EFFECT_SPEC).length, 10) console.log('theme profiles contract: PASS') JS harness="$repo_dir/config/dot/quickshell/theme-profiles-harness.qml" state_home="$(mktemp -d /tmp/panama-theme-profiles.XXXXXX)" qs_for_test() { XDG_CONFIG_HOME="$state_home/config" XDG_STATE_HOME="$state_home/state" \ QS_DISABLE_CRASH_HANDLER=1 qs -p "$harness" "$@" } cleanup() { qs_for_test kill >/dev/null 2>&1 || true rm -rf "$state_home" } trap cleanup EXIT qs_for_test --daemonize >/dev/null for _ in $(seq 1 100); do qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' && break sleep 0.1 done qs_for_test ipc show 2>/dev/null | rg -q '^target theme-profiles-test$' \ || { printf 'theme profiles contract: test IPC target did not start\n' >&2; exit 1; } status() { qs_for_test ipc call theme-profiles-test status; } # The catalog is live here, so the profile list is the ten shipped themes, not # the three-entry pre-load fallback. The catalog arrives through an async # FileView read, so under a loaded machine (the full suite) it can land a # beat after the IPC target does -- wait for it rather than asserting the # race. for _ in $(seq 1 50); do jq -e '(.profiles | length) == 10' <<<"$(status)" >/dev/null 2>&1 && break sleep 0.1 done jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null \ || { printf 'theme profiles contract: catalog never loaded ten themes: %s\n' "$(status)" >&2; exit 1; } # ── A scheme flip lands on the remembered theme for that side ─────────────── # This is the whole point of themeDark/themeLight. Before them, flipping to # light forced Day and flipping back forced Moon, so choosing Everforest and # then turning on the light meant losing it. qs_for_test ipc call theme-profiles-test scheme light >/dev/null jq -e '.active.id == "day" and .active.scheme == "light"' <<<"$(status)" >/dev/null qs_for_test ipc call theme-profiles-test select latte >/dev/null jq -e '.active.id == "latte" and .active.scheme == "light"' <<<"$(status)" >/dev/null qs_for_test ipc call theme-profiles-test scheme dark >/dev/null jq -e '.active.id == "moon" and .active.scheme == "dark"' <<<"$(status)" >/dev/null qs_for_test ipc call theme-profiles-test select nord >/dev/null jq -e '.active.id == "nord" and .active.scheme == "dark"' <<<"$(status)" >/dev/null qs_for_test ipc call theme-profiles-test scheme light >/dev/null jq -e '.active.id == "latte"' <<<"$(status)" >/dev/null \ || { printf 'theme profiles contract: light did not return to the theme chosen there\n' >&2; exit 1; } qs_for_test ipc call theme-profiles-test scheme dark >/dev/null jq -e '.active.id == "nord"' <<<"$(status)" >/dev/null \ || { printf 'theme profiles contract: dark did not return to the theme chosen there\n' >&2; exit 1; } # ── Editing a shipped theme forks it, carrying its palette ────────────────── qs_for_test ipc call theme-profiles-test edit '#a3be8c' '#88c0d0' >/dev/null edited="$(status)" jq -e '.active.shipped == false and .active.accent == "#a3be8c" and .active.name == "Nord custom" and (.stored | length) == 1 and (.active.palette | length) == 19' <<<"$edited" >/dev/null custom_id="$(jq -r '.active.id' <<<"$edited")" qs_for_test ipc call theme-profiles-test save ' Forest ' >/dev/null saved="$(status)" jq -e '.active.name == "Forest" and (.stored | length) == 2 and (.active.effects | length) == 10' <<<"$saved" >/dev/null \ || { printf 'theme profiles contract: saving did not snapshot the effects\n' >&2; exit 1; } forest_id="$(jq -r '.active.id' <<<"$saved")" # ── Shipped themes cannot be deleted; deleting the active custom falls back ─ [[ "$(qs_for_test ipc call theme-profiles-test remove nord)" == "false" ]] [[ "$(qs_for_test ipc call theme-profiles-test remove "$forest_id")" == "true" ]] # themeDark pointed at the deleted record, so resolution falls through to the # catalog default rather than leaving the desktop on a theme that is gone. jq -e '.active.id == "moon" and .active.shipped == true' <<<"$(status)" >/dev/null [[ "$(qs_for_test ipc call theme-profiles-test remove "$custom_id")" == "true" ]] jq -e '(.stored | length) == 0' <<<"$(status)" >/dev/null trap - EXIT cleanup printf 'theme profiles service contract: PASS\n'