Files
Panama/config/dot/quickshell/scripts/panama-palette
T
Gabriel Brown cb7c09d208 Give the desktop real themes, video wallpapers, and honest titlebars
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
2026-08-23 23:39:04 -04:00

289 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# The accent palette and the theme resolver, for the shell scripts that need
# them.
#
# Sourced, not run:
#
# source "$(dirname "$0")/panama-palette"
# accent_hex orchid dark # -> c099ff
# hex_to_rgb c099ff # -> 192, 153, 255
# eval "$(theme_vars "$(resolve_theme dark)")" # -> pal_bg, ansi_red, ...
#
# Why this exists: the same eight accents were written out by hand in five
# places -- Theme.qml through ThemeProfileModel.js, hypr/looks.lua,
# panama-theme-apps and panama-lock -- and panama-theme-apps said so in a
# comment admitting "there is no shared source between QML and a shell script".
# 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 with a
# stale color and nothing says why.
#
# config/palette.json is now that shared source. QML still carries the table in
# ThemeProfileModel.js, because a .js module imported into QML cannot read a
# file -- but the two are checked against each other by
# tests/quickshell/palette-contract, so drift is a failing test rather than a
# lock screen in last season's colour.
# The palette lives beside the shell config. Resolved from this script's own
# location so it is correct wherever the repository is checked out.
PANAMA_PALETTE="${PANAMA_PALETTE:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/palette.json}"
# The hex for one accent in one scheme, without the leading '#'.
#
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
# accentPair fallback: a caller that only ever knew about the scheme still gets
# a real accent rather than an empty string reaching sed.
accent_hex() {
local name="${1:-}" scheme="${2:-dark}" key value
[[ "$scheme" == "light" ]] && key="light" || key="dark"
value="$(jq -r --arg n "$name" --arg k "$key" \
'.accents[$n][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)"
if [[ -z "$value" ]]; then
value="$(jq -r --arg k "$key" \
'.accents[.default][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)"
fi
# Last resort, for a palette file that is missing or unreadable. Shipping
# blue is better than shipping an empty string into a config generator.
[[ -n "$value" ]] || value="$([[ "$key" == light ]] && echo 2e7de9 || echo 82aaff)"
printf '%s' "$value"
}
# The second half of the pair, for the two-stop gradient the window border uses.
accent_secondary_hex() {
local name="${1:-}" scheme="${2:-dark}" key value
[[ "$scheme" == "light" ]] && key="lightSecondary" || key="darkSecondary"
value="$(jq -r --arg n "$name" --arg k "$key" \
'.accents[$n][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)"
if [[ -z "$value" ]]; then
value="$(jq -r --arg k "$key" \
'.accents[.default][$k] // empty' "$PANAMA_PALETTE" 2>/dev/null)"
fi
[[ -n "$value" ]] || value="$([[ "$key" == lightSecondary ]] && echo 9854f1 || echo b172b0)"
printf '%s' "$value"
}
# GNOME's accent-color is a FIXED enum of nine names -- there is no hex to
# match, only a nearest member to pick -- so palette.json records which one
# each Panama accent maps to. libadwaita applications are told this, so
# choosing an accent here recolours Files and Papers too.
accent_gnome() {
local name="${1:-}" value
value="$(jq -r --arg n "$name" '.accents[$n].gnome // empty' "$PANAMA_PALETTE" 2>/dev/null)"
[[ -n "$value" ]] || value="$(jq -r '.accents[.default].gnome // empty' "$PANAMA_PALETTE" 2>/dev/null)"
[[ -n "$value" ]] || value="blue"
printf '%s' "$value"
}
# hyprlock wants "R, G, B" decimal, not hex.
hex_to_rgb() {
local hex="${1#\#}"
printf '%d, %d, %d' "0x${hex:0:2}" "0x${hex:2:2}" "0x${hex:4:2}"
}
# ── Themes ───────────────────────────────────────────────────────────────────
#
# An accent is one colour; a THEME is the whole palette plus that accent pair.
# Shipped themes live in config/themes.json, custom ones in settings.json's
# themeProfiles array, and the two shell generators that render application
# configuration -- panama-theme-apps and panama-lock -- both have to resolve
# the same active theme from those two files. Doing that twice is how the
# hyprlock colours came to be written out in three places; the resolution
# lives here for the same reason accent_hex does.
# The catalog lives beside the shell config, resolved from this script's own
# location so it is correct wherever the repository is checked out.
PANAMA_THEMES="${PANAMA_THEMES:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)/config/themes.json}"
# Read at call time rather than baked in at source time, so a caller (or a
# test) that sets XDG_CONFIG_HOME after sourcing still gets the right file.
panama_settings_file() {
printf '%s' "${PANAMA_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
}
# The complete theme record for one scheme, as compact JSON:
#
# { id, scheme, accent, secondary, palette: {19 keys}, ansi: {16 keys} }
#
# Resolution order, per scheme: the per-mode preference (themeDark/themeLight),
# then themeProfileId when it names a theme of that scheme, then the catalog's
# default for the scheme. A record found in either place wins only where it
# actually carries a value -- palette and ansi are MERGED over the scheme
# default's, so a custom theme saved with only an accent (every profile stored
# before themes existed) still resolves to a complete palette rather than to
# nothing.
resolve_theme() {
local scheme="${1:-dark}" settings settings_json='{}' catalog_json='{}'
[[ "$scheme" == "light" ]] || scheme="dark"
settings="$(panama_settings_file)"
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_json="$(cat "$settings")"
fi
if [[ -r "$PANAMA_THEMES" ]] && jq -e 'type == "object"' "$PANAMA_THEMES" >/dev/null 2>&1; then
catalog_json="$(cat "$PANAMA_THEMES")"
fi
jq -cn --arg scheme "$scheme" --argjson settings "$settings_json" \
--argjson catalog "$catalog_json" '
def shipped: ($catalog.themes // []) | map(select(type == "object"));
def customs: ($settings.themeProfiles // []) | map(select(type == "object"));
def defaultId($s): if $s == "light" then ($catalog.defaultLight // "day")
else ($catalog.defaultDark // "moon") end;
def byId($id): (shipped | map(select(.id == $id)) | first)
// (customs | map(select(.id == $id)) | first);
def forScheme($id): byId($id) | select(. != null and ((.scheme // "dark") == $scheme));
(byId(defaultId($scheme)) // {}) as $base
| ( forScheme($settings[if $scheme == "light" then "themeLight" else "themeDark" end] // "")
// forScheme($settings.themeProfileId // "")
// $base ) as $theme
| { id: ($theme.id // defaultId($scheme)),
scheme: $scheme,
accent: ($theme.accent // $base.accent // ""),
secondary: ($theme.secondary // $base.secondary // ""),
palette: (($base.palette // {}) + ($theme.palette // {})),
ansi: (($base.ansi // {}) + ($theme.ansi // {})) }
' 2>/dev/null \
|| printf '{"id":"","scheme":"%s","accent":"","secondary":"","palette":{},"ansi":{}}' "$scheme"
}
# A theme record as eval-able shell assignments, so a generator reads a colour
# as "$pal_bg" rather than paying a jq invocation per token:
#
# eval "$(theme_vars "$(resolve_theme dark)")"
# printf 'background #%s\n' "$pal_bg"
#
# The optional prefix lets one script hold two themes at once -- Firefox needs
# both schemes in a single stylesheet so the browser can flip on its own.
#
# Every value is validated to six lowercase hex digits before it is emitted and
# anything else becomes the empty string, so the eval can never run something
# a settings file said. A caller must therefore treat "" as "not themed" rather
# than substituting it into a config.
theme_vars() {
local json="${1:-\{\}}" prefix="${2:-}"
jq -r --arg p "$prefix" '
def hex: if type == "string"
and (ascii_downcase | ltrimstr("#") | test("^[0-9a-f]{6}$"))
then (ascii_downcase | ltrimstr("#")) else "" end;
((.palette // {}) | to_entries[]
| select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
| "\($p)pal_\(.key)=\(.value | hex)"),
((.ansi // {}) | to_entries[]
| select(.key | test("^[A-Za-z][A-Za-z0-9]*$"))
| "\($p)ansi_\(.key)=\(.value | hex)"),
"\($p)theme_accent=\(.accent | hex)",
"\($p)theme_secondary=\(.secondary | hex)",
"\($p)theme_id=\(((.id // "") | if test("^[A-Za-z0-9._-]+$") then . else "" end))"
' <<<"$json" 2>/dev/null || true
}
# First non-empty of the two. Written out because `[[ -n $a ]] && printf $a`
# returns 1 when it is empty, which `set -e` treats as a failure.
hex_or() {
if [[ -n "${1:-}" ]]; then printf '%s' "$1"; else printf '%s' "${2:-}"; fi
}
# Blend two colours: mix_hex <base> <tint> <percent of tint>. Used to derive
# the in-between surfaces (a selected row, a grid tile) that no palette token
# names but every application theme wants.
mix_hex() {
local base="${1#\#}" tint="${2#\#}" percent="${3:-50}" offset out=""
local from to blended
for offset in 0 2 4; do
from=$((16#${base:offset:2}))
to=$((16#${tint:offset:2}))
blended=$(( (from * (100 - percent) + to * percent) / 100 ))
out+="$(printf '%02x' "$blended")"
done
printf '%s' "$out"
}
# Perceived luminance, 0-255. Not the arithmetic mean: green reads far brighter
# than blue at the same value, and the mean puts white text on a yellow accent.
luma_hex() {
local hex="${1#\#}" red green blue
red=$((16#${hex:0:2})); green=$((16#${hex:2:2})); blue=$((16#${hex:4:2}))
printf '%d' $(( (red * 299 + green * 587 + blue * 114) / 1000 ))
}
# Whether dark text belongs on top of this colour.
is_light_hex() {
(( $(luma_hex "$1") >= 140 ))
}
# The colour to draw text in on top of a filled surface -- an accent button, a
# selected row. Takes two of the theme's own colours rather than black and
# white, so the result keeps the theme's cast.
#
# The two candidates are compared by their own luminance rather than trusted to
# arrive in a fixed order. Passing "the dark one" first only works on a dark
# theme: on a light one the foreground IS the dark colour, which is how a light
# theme ended up with blue label text on a blue button.
on_hex() {
local surface="${1:-}" first="${2:-}" second="${3:-}" first_luma second_luma
[[ -n "$first" ]] || { printf '%s' "$second"; return; }
[[ -n "$second" && -n "$surface" ]] || { printf '%s' "$first"; return; }
first_luma="$(luma_hex "$first")"
second_luma="$(luma_hex "$second")"
if is_light_hex "$surface"; then
(( first_luma <= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
else
(( first_luma >= second_luma )) && printf '%s' "$first" || printf '%s' "$second"
fi
}
# The lock screen, rendered from a theme into hyprlock's config language.
#
# This lives here rather than in panama-theme-apps because two other callers
# need the same eight substitutions: setup/scripts/link-dotfiles seeds the
# fallback config at install time, and panama-lock renders the generated one.
# All three used to carry their own table of literal rgba triples, and the copy
# most likely to be missed was the lock screen's -- which fails silently, with
# the machine locked in last season's colour and nothing to say why.
#
# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
# template carries "R, G, B" triples. The two _HEX placeholders are the
# exception: they sit inside Pango markup, which wants ##rrggbb.
#
# render_hyprlock <dark|light> <template> <destination> [accent-name]
#
# Returns non-zero without touching the destination when the template is
# unreadable or the theme resolves without a palette -- a half-substituted
# config is not a parse error to hyprlock, it is a set of colours it quietly
# ignores in favour of its own bright grey defaults.
render_hyprlock() {
local scheme="${1:-dark}" template="${2:-}" destination="${3:-}" accent_name="${4:-blue}"
[[ -r "$template" && -n "$destination" ]] || return 1
(
set -euo pipefail
eval "$(theme_vars "$(resolve_theme "$scheme")")"
local accent
accent="$(hex_or "${theme_accent:-}" "$(accent_hex "$accent_name" "$scheme")")"
for value in "${pal_fg:-}" "${pal_fgMuted:-}" "${pal_red:-}" "${pal_bg:-}" \
"${pal_bgPanel:-}" "$accent"; do
[[ -n "$value" ]] || exit 1
done
# Written atomically: a lock triggered mid-write would otherwise read a
# truncated config and fall back to hyprlock's own defaults, which is a
# bright grey screen with none of this desktop's identity.
local temporary="$destination.tmp.$$"
sed -e "s/@FG@/$(hex_to_rgb "$pal_fg")/g" \
-e "s/@MUTED@/$(hex_to_rgb "$pal_fgMuted")/g" \
-e "s/@ACCENT@/$(hex_to_rgb "$accent")/g" \
-e "s/@ERROR@/$(hex_to_rgb "$pal_red")/g" \
-e "s/@BG@/$(hex_to_rgb "$pal_bg")/g" \
-e "s/@FIELD@/$(hex_to_rgb "$pal_bgPanel")/g" \
-e "s/@MUTED_HEX@/$pal_fgMuted/g" \
-e "s/@ERROR_HEX@/$pal_red/g" \
"$template" >"$temporary" 2>/dev/null \
&& mv "$temporary" "$destination" 2>/dev/null \
|| { rm -f "$temporary" 2>/dev/null || true; exit 1; }
)
}