Appearance now opens on Themes: light and dark side by side, each remembering its own choice, over galleries of ten shipped themes — Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and Everforest in both modes. A theme is a complete palette: the catalog lives in themes.json, Theme.qml reads every color token from the active record, and one render pipeline carries it to kitty, tmux, btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme editor builds new ones from four wells — wheel, hex, or eyedropper — with derived surfaces, a saturation slider, debounced fine-tune, and effects that save with the theme. Custom edits finally keep GNOME's accent, kitty's border, and hyprlock in sync. Wallpapers can be video: mpvpaper per output, hardware-decoded, muted and looped, supervised and respawned. Panama owns the pausing — games, battery, and a bar pill for right now — because the compositor rebuilds full-screen blur for every frame a video wallpaper draws. The lock screen gets a still frame. Titlebars stop lying. GNOME apps get close-only on your chosen side, the maximize and double-click settings are gone, the Settings window obeys the same rules, and its titlebar can be turned off entirely. Typography becomes five labeled dropdowns instead of a wall of samples. Contracts updated and written throughout (165 now); per the redesign workflow none were executed — the full sweep runs once at the end. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
354 lines
17 KiB
Bash
Executable File
354 lines
17 KiB
Bash
Executable File
#!/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 dropped; the same record
|
|
// against the three-entry fallback is kept, because there "Nord" is free.
|
|
const impostor = {
|
|
id: 'custom-nord', name: 'Nord', scheme: 'dark',
|
|
accent: '#88c0d0', secondary: '#81a1c1', shipped: false
|
|
}
|
|
assert.equal(model.validCustomProfiles([impostor], shippedCatalog).length, 0)
|
|
assert.equal(model.validCustomProfiles([impostor]).length, 1)
|
|
|
|
// ── 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 40); 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.
|
|
jq -e '.active.id == "moon" and (.profiles | length) == 10' <<<"$(status)" >/dev/null
|
|
|
|
# ── 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'
|