#!/usr/bin/env bash # The eight accents, and the one place they are written down. # # They used to be written down five times: ThemeProfileModel.js for QML, # hypr/looks.lua for the compositor, and again in panama-theme-apps and # panama-lock. panama-theme-apps said so in its own comment -- "there is no # shared source between QML and a shell script" -- which is an accurate # description of a bug waiting to happen. Adding a ninth accent meant editing # five files, and the one most likely to be missed was the lock screen, which # fails silently: the machine locks in a stale colour and nothing says why. # # config/palette.json is now that source. Everything outside QML reads it: # looks.lua through prefs.readJson, the two shell generators through # scripts/panama-palette. # # QML still carries the table, because a .js module imported into QML cannot # read a file. That is a copy, and copies drift, so this contract exists to # make the drift a failing test instead of a wrong lock screen. It compares # them value by value rather than checking that both merely exist. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" palette="$repo_dir/config/dot/quickshell/config/palette.json" model="$repo_dir/config/dot/quickshell/services/ThemeProfileModel.js" helper="$repo_dir/config/dot/quickshell/scripts/panama-palette" looks="$repo_dir/config/dot/hypr/looks.lua" theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps" lock="$repo_dir/config/dot/quickshell/scripts/panama-lock" findings=() note() { findings+=("$1"); } [[ -r "$palette" ]] || { printf 'palette contract: %s is missing\n' "$palette" >&2; exit 1; } jq -e . "$palette" >/dev/null 2>&1 || { printf 'palette contract: palette.json is not valid JSON\n' >&2; exit 1; } # ── The QML table and the palette agree, value by value ────────────────────── python3 - "$palette" "$model" <<'PY' || note 'the QML accent table and config/palette.json disagree; see the lines above' import json, re, sys palette = json.load(open(sys.argv[1]))["accents"] source = open(sys.argv[2], encoding="utf-8").read() block = source[source.index("var CURATED = {"):] block = block[:block.index("\n};") + 3] qml = {} for match in re.finditer( r'(\w+):\s*\{\s*dark:\s*"#([0-9a-f]{6})",\s*darkSecondary:\s*"#([0-9a-f]{6})",' r'\s*light:\s*"#([0-9a-f]{6})",\s*lightSecondary:\s*"#([0-9a-f]{6})",' r'\s*label:\s*"([^"]*)",\s*gnome:\s*"([^"]*)"', block): name, dark, dark2, light, light2, label, gnome = match.groups() qml[name] = {"dark": dark, "darkSecondary": dark2, "light": light, "lightSecondary": light2, "label": label, "gnome": gnome} problems = [] for name in sorted(set(palette) | set(qml)): if name not in palette: problems.append(f"{name}: in the QML table but not in palette.json") continue if name not in qml: problems.append(f"{name}: in palette.json but not in the QML table") continue for field in ("dark", "darkSecondary", "light", "lightSecondary", "label", "gnome"): if palette[name].get(field) != qml[name].get(field): problems.append( f"{name}.{field}: palette.json has {palette[name].get(field)!r}, " f"the QML table has {qml[name].get(field)!r}") if problems: print("\n".join(" " + p for p in problems), file=sys.stderr) raise SystemExit(1) PY # ── Every consumer reads it rather than restating it ───────────────────────── grep -q 'palette.json' "$helper" || note 'the shell palette helper does not read palette.json' grep -q 'panama-palette' "$theme_apps" || note 'panama-theme-apps does not use the shared palette helper' grep -q 'panama-palette' "$lock" || note 'panama-lock does not use the shared palette helper' grep -q 'prefs.readJson' "$looks" || note 'looks.lua does not read the palette, so the compositor has its own copy again' # A restated table is the thing this exists to prevent. Each of these once held # all eight; a file holding more than a single fallback pair has grown one back. for file in "$theme_apps" "$lock"; do count="$(grep -cE '^\s*(orchid|teal|green|amber|orange|rose|slate)\)' "$file" || true)" (( count == 0 )) || note "$(basename "$file") has an accent table again ($count entries)" done looks_accents="$(grep -cE '^\s+(orchid|teal|green|amber|orange|rose|slate)\s*=' "$looks" || true)" (( looks_accents == 0 )) || note "looks.lua has an accent table again ($looks_accents entries)" # ── The same argument, one level up: the THEME resolver ────────────────────── # # An accent is one colour. A theme is the whole palette, and the two shell # generators both need the active one -- panama-theme-apps to render kitty, # GTK, btop, tmux, Vicinae and Firefox, panama-lock to render the lock screen. # They answered that question with their own tables of Tokyo Night literals # until the resolution moved into panama-palette beside accent_hex, for exactly # the reason recorded above. link-dotfiles is the third caller: it seeds the # lock screen at install time. themes="$repo_dir/config/dot/quickshell/config/themes.json" linker="$repo_dir/setup/scripts/link-dotfiles" grep -q 'themes.json' "$helper" || note 'the shell palette helper does not read themes.json, so nothing shared resolves a theme' for consumer in "$theme_apps" "$lock"; do grep -q 'resolve_theme' "$consumer" \ || note "$(basename "$consumer") does not resolve the active theme through the shared function" done grep -q 'render_hyprlock' "$linker" \ || note 'link-dotfiles seeds the lock screen without the shared renderer, so it carries its own colour table again' # ── The resolver answers with a complete theme ─────────────────────────────── if [[ -r "$themes" ]] && jq -e . "$themes" >/dev/null 2>&1; then resolver_fixture="$(mktemp -d /tmp/panama-theme-resolve.XXXXXX)" trap 'rm -rf "$resolver_fixture"' EXIT mkdir -p "$resolver_fixture/panama" while read -r id; do scheme="$(jq -r --arg id "$id" '.themes[] | select(.id == $id) | .scheme' "$themes")" jq -n --arg id "$id" '{themeProfileId: $id}' >"$resolver_fixture/panama/settings.json" resolved="$(XDG_CONFIG_HOME="$resolver_fixture" \ bash -c "source '$helper'; resolve_theme '$scheme'")" [[ "$(jq -r '.id' <<<"$resolved")" == "$id" ]] \ || note "the resolver does not find the shipped theme '$id'" # 19 palette tokens and 16 ansi keys, or a generator writes a config # with a colour of "" in it. [[ "$(jq -r '.palette | length' <<<"$resolved")" == "19" ]] \ || note "'$id' resolves with $(jq -r '.palette | length' <<<"$resolved") palette keys rather than 19" [[ "$(jq -r '.ansi | length' <<<"$resolved")" == "16" ]] \ || note "'$id' resolves with $(jq -r '.ansi | length' <<<"$resolved") ansi keys rather than 16" done < <(jq -r '.themes[].id' "$themes") # A custom carrying only an accent -- which is every profile saved before # themes existed. It must keep its accent AND inherit a full palette. jq -n '{themeProfileId: "bare", themeProfiles: [{id: "bare", name: "Bare", scheme: "dark", accent: "#ff0088", secondary: "#00ffcc"}]}' \ >"$resolver_fixture/panama/settings.json" bare="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")" [[ "$(jq -r '.accent' <<<"$bare")" == "#ff0088" ]] \ || note 'a custom theme without a palette loses its own accent' default_bg="$(jq -r --arg id "$(jq -r '.defaultDark' "$themes")" \ '.themes[] | select(.id == $id) | .palette.bg' "$themes")" [[ "$(jq -r '.palette.bg' <<<"$bare")" == "$default_bg" ]] \ || note 'a custom theme without a palette does not inherit the scheme default, so every profile saved before themes existed resolves to no colours at all' # An unknown id must land on the scheme default rather than on an empty # record: a settings file naming a deleted theme is a normal state. jq -n '{themeProfileId: "no-such-theme"}' >"$resolver_fixture/panama/settings.json" unknown="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme light")" [[ "$(jq -r '.id' <<<"$unknown")" == "$(jq -r '.defaultLight' "$themes")" ]] \ || note "an unknown theme id resolves to '$(jq -r '.id' <<<"$unknown")' rather than to the light default" # The per-mode keys are the selection; themeProfileId is the fallback. jq -n --arg dark "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" \ '{themeProfileId: "day", themeDark: $dark}' >"$resolver_fixture/panama/settings.json" per_mode="$(XDG_CONFIG_HOME="$resolver_fixture" bash -c "source '$helper'; resolve_theme dark")" [[ "$(jq -r '.id' <<<"$per_mode")" == "$(jq -r '[.themes[] | select(.scheme == "dark")][-1].id' "$themes")" ]] \ || note 'themeDark does not win over themeProfileId, so a scheme flip lands on the wrong theme' fi # ── The helper resolves what the palette says ──────────────────────────────── while read -r name; do for scheme in dark light; do want="$(jq -r --arg n "$name" --arg s "$scheme" '.accents[$n][$s]' "$palette")" got="$(bash -c "source '$helper'; accent_hex '$name' '$scheme'")" [[ "$got" == "$want" ]] \ || note "the helper resolves $name/$scheme as '$got'; the palette says '$want'" done done < <(jq -r '.accents | keys[]' "$palette") # An unknown accent must fall back rather than produce an empty string, which # would reach sed and generate a config with a colour of "". fallback="$(bash -c "source '$helper'; accent_hex 'not-an-accent' dark")" [[ "$fallback" == "$(jq -r '.accents[.default].dark' "$palette")" ]] \ || note "an unknown accent resolves to '$fallback' rather than the default" empty="$(bash -c "source '$helper'; accent_hex '' dark")" [[ -n "$empty" ]] || note 'an empty accent name resolves to an empty string, which would reach a config generator' # ── Lua reads it too ───────────────────────────────────────────────────────── if command -v lua >/dev/null 2>&1; then lua_result="$(cd "$repo_dir/config/dot/hypr" && PANAMA_TEST_PALETTE="$palette" lua -e ' package.path = "./?.lua;" .. package.path local prefs = require("prefs") local parsed = prefs.readJson(os.getenv("PANAMA_TEST_PALETTE")) local accents = parsed.accents if not accents then print("none") os.exit(0) end local count = 0 for _ in pairs(accents) do count = count + 1 end print(count .. " " .. tostring(accents.orchid and accents.orchid.dark)) ' 2>/dev/null)" expected_count="$(jq -r '.accents | length' "$palette")" expected_orchid="$(jq -r '.accents.orchid.dark' "$palette")" [[ "$lua_result" == "$expected_count $expected_orchid" ]] \ || note "Lua reads the palette as '$lua_result', expected '$expected_count $expected_orchid'" # A palette that cannot be read must cost the colours and nothing else. lua_missing="$(cd "$repo_dir/config/dot/hypr" && PANAMA_TEST_PALETTE="/nonexistent/palette.json" lua -e ' package.path = "./?.lua;" .. package.path local prefs = require("prefs") local parsed = prefs.readJson(os.getenv("PANAMA_TEST_PALETTE")) print(type(parsed) == "table" and "table" or "raised") ' 2>&1)" [[ "$lua_missing" == "table" ]] \ || note 'reading a missing palette does not return an empty table, so a bad file would break the compositor config' fi if (( ${#findings[@]} > 0 )); then printf 'palette contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'palette contract: PASS (%s accents, one source)\n' "$(jq -r '.accents | length' "$palette")"