#!/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 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")"