Finish threading the accent colour through the whole desktop

The recent accent-colour setting only reached part of the desktop.
looks.lua still hardcoded the focused-window border and glow to blue,
so any hyprctl reload -- or every fresh session, for about a second --
reverted a chosen accent; it now reads accentName the same way it
already read colorScheme. The lock screen and terminal stayed blue
regardless of the chosen accent despite the setting's own description
claiming otherwise; panama-lock and panama-theme-apps now resolve and
apply the real accent.

AccentPicker built its swatch model from Theme.accents directly
instead of the schema's own options list, so the two could drift
silently; switched it to read the schema. Its hit target only covered
the swatch, not the name label added specifically for colour-vision
accessibility -- extended to the whole row. Settings search had no
route for the "appearance" group, so searching for the accent or
colour scheme landed on Home.

ColorScheme's hex-to-Hyprland helper assumed 6-digit colours and would
silently corrupt a future translucent one; fixed it to read from the
end of the string instead of the start. An accent-only change no
longer reruns the full colour-scheme pipeline. The gradient it builds
for the focused border now goes through SystemSettings' existing
serialiser instead of a second, under-escaped copy of the same logic.

The settings-ownership contract test enforced the old rule that
ColorScheme must never touch the focused border; updated it to verify
the real, intended rule instead of contradicting the code.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
This commit is contained in:
Gabriel Brown
2026-08-18 21:23:16 -04:00
parent 8b59b78d9f
commit 9ba224d776
9 changed files with 301 additions and 83 deletions
+35 -7
View File
@@ -2,7 +2,7 @@
-- Look and feel -- Tokyo Night Moon -- Look and feel -- Tokyo Night Moon
-- --
-- Colours here must stay in sync with quickshell/config/Theme.qml. -- Colours here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus -- accent user-selectable, see `accents` below -- blue (#82aaff) ships
-- bg #222436 base -- bg #222436 base
-- --
-- Performance note: every animation below is event-driven. Nothing uses the -- Performance note: every animation below is event-driven. Nothing uses the
@@ -14,6 +14,30 @@
local prefs = require("prefs") local prefs = require("prefs")
-- Mirrors config/Theme.qml's `accents` map. Lua has no import path into a QML
-- singleton, so the hex pairs are restated here -- just the four hex strings
-- each named accent needs, not the labels, which stay UI-only.
local accents = {
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
orchid = { dark = "c099ff", darkSecondary = "fca7ea", light = "7847bd", lightSecondary = "9854f1" },
teal = { dark = "86e1fc", darkSecondary = "82aaff", light = "007197", lightSecondary = "2e7de9" },
green = { dark = "c3e88d", darkSecondary = "86e1fc", light = "587539", lightSecondary = "007197" },
amber = { dark = "ffc777", darkSecondary = "ff966c", light = "8c6c3e", lightSecondary = "b15c00" },
orange = { dark = "ff966c", darkSecondary = "ff757f", light = "b15c00", lightSecondary = "c64343" },
rose = { dark = "ff757f", darkSecondary = "c099ff", light = "f52a65", lightSecondary = "9854f1" },
slate = { dark = "828bb8", darkSecondary = "82aaff", light = "6172b0", lightSecondary = "2e7de9" },
}
-- The accent pair a fresh session or `hyprctl reload` starts from.
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
-- data, once the shell settles (~1.2s after startup -- see its `settle`
-- Timer). This table exists only so the compositor is never observably blue
-- for a non-blue accent during the gap before that first live apply.
local accentScheme = prefs.get("colorScheme", "dark")
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
hl.config({ hl.config({
general = { general = {
gaps_in = prefs.get("gapsIn", 5), gaps_in = prefs.get("gapsIn", 5),
@@ -22,11 +46,13 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The focused accent role: blue leads, orchid follows, on a -- The focused accent role, on a diagonal so the pair is visible
-- diagonal so the pair is visible on both a tall and a wide -- on both a tall and a wide window. Driven by the chosen
-- window. ColorScheme never writes this role; a future accent -- accentName (see the `accents` table above); services/
-- picker can own it without fighting light/dark mode. -- ColorScheme.qml applies the same values live, and restates them
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, -- from Theme.accent/Theme.accentSecondary on every scheme change
-- too, since each accent carries a separate pair per scheme.
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
-- The neutral inactive role follows the colour scheme because a -- The neutral inactive role follows the colour scheme because a
-- dark neutral disappears against a light desktop. -- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is -- services/ColorScheme.qml applies the same values live; this is
@@ -104,11 +130,13 @@ hl.config({
-- New in 0.56. Kept deliberately faint: in this direction the gradient -- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it. -- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper. -- This is just enough to lift the focused window off the wallpaper.
-- Derived from the same accent as active_border above, not hardcoded,
-- so the halo never disagrees with the border it surrounds.
glow = { glow = {
enabled = prefs.get("glowEnabled", true), enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8), range = prefs.get("glowRange", 8),
render_power = 2, render_power = 2,
color = "rgba(82aaff33)", color = "rgba(" .. accentStart .. "33)",
color_inactive = "rgba(00000000)", color_inactive = "rgba(00000000)",
}, },
@@ -20,23 +20,45 @@ Flow {
readonly property string current: DesktopPreferences.get("accentName") || "blue" readonly property string current: DesktopPreferences.get("accentName") || "blue"
// The schema's own option list, not Theme.accents directly -- two lists
// hand-kept in sync is how they drift. This is the same pattern
// ChoiceRow.qml uses for every other enum row.
readonly property var spec: PreferenceSchema.spec("accentName")
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
Repeater { Repeater {
// Object key order is insertion order here, so the palette's own order // The schema's option order is the palette's order, so blue is first
// is what the user sees; blue first because it is what Panama ships. // because it is what Panama ships.
model: Object.keys(Theme.accents) model: root.options
Column { Column {
id: entry id: entry
required property var modelData required property var modelData
readonly property var pair: Theme.accents[entry.modelData] readonly property string name: entry.modelData.value
readonly property bool selected: entry.modelData === root.current readonly property var pair: Theme.accents[entry.name]
readonly property bool selected: entry.name === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5 spacing: 5
// The hit target is the whole swatch+label unit, not just the
// 46px circle: the label exists specifically so someone with a
// colour vision deficiency can identify an accent without it, and
// a label that cannot itself be tapped defeats that.
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (!SystemSettings.commitPreference("accentName", entry.name))
console.warn("AccentPicker: commitPreference rejected accent", entry.name);
}
}
Rectangle { Rectangle {
width: 46 width: 46
height: 46 height: 46
@@ -59,12 +81,6 @@ Flow {
GradientStop { position: 1.0; color: entry.end } GradientStop { position: 1.0; color: entry.end }
} }
} }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: SystemSettings.commitPreference("accentName", entry.modelData)
}
} }
// Always shown, not a tooltip. Telling swatches apart by colour is // Always shown, not a tooltip. Telling swatches apart by colour is
@@ -73,7 +89,7 @@ Flow {
// hiding the names behind a hover would waste that. // hiding the names behind a hover would waste that.
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: entry.pair.label text: entry.modelData.label
color: entry.selected ? Theme.fg : Theme.fgMuted color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -96,9 +96,11 @@ reason and an update to `tests/quickshell/settings-ownership-contract.sh`.
Window border colour follows the same ownership rule. The inactive border is a Window border colour follows the same ownership rule. The inactive border is a
**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain **scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain
neutral contrast in light and dark modes. The focused Prism border is the neutral contrast in light and dark modes. The focused Prism border is the
accent role owned by the visual theme (and, eventually, an accent picker). **accent role**, driven by the chosen `accentName` and also owned by
`ColorScheme.qml` must never write the focused border, so changing schemes `ColorScheme.qml`: each accent carries a separate pair for light and dark, so
cannot erase a user-selected accent. `ColorScheme.qml` restates the focused border alongside the inactive one on
every scheme change, rather than leaving a scheme flip to erase a
user-selected accent.
## The rows ## The rows
+42 -7
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generates Panama's hyprlock configuration into the state directory. The # Generates Panama's hyprlock configuration into the state directory. The
# tracked config is never rewritten and remains the fallback if generation # gitignored fallback config (rendered from the tracked .template at link
# fails, so a malformed preference can never leave the session without a # time) is never rewritten by this script and remains the fallback if
# working locker. # generation fails, so a malformed preference can never leave the session
# without a working locker.
set -euo pipefail set -euo pipefail
@@ -78,6 +79,31 @@ valid_path() {
[[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]] [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
} }
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: the lock screen shows a flat focus ring, not the
# two-stop gradient the window border does. Falls back to blue for an unknown
# name, matching Theme.accentPair's own fallback.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# 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}"
}
load_preferences() { load_preferences() {
if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
settings_valid=true settings_valid=true
@@ -99,6 +125,12 @@ load_preferences() {
color_scheme="$(read_string colorScheme dark)" color_scheme="$(read_string colorScheme dark)"
[[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark [[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
accent_name="$(read_string accentName blue)"
case "$accent_name" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent_name=blue ;;
esac
wallpaper_mode="$(read_string wallpaperMode single)" wallpaper_mode="$(read_string wallpaperMode single)"
[[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \ [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
|| wallpaper_mode=single || wallpaper_mode=single
@@ -121,8 +153,6 @@ load_preferences() {
background_color='rgba(225, 226, 231, 1.0)' background_color='rgba(225, 226, 231, 1.0)'
foreground_color='rgba(55, 96, 191, 1.0)' foreground_color='rgba(55, 96, 191, 1.0)'
dim_color='rgba(97, 114, 176, 1.0)' dim_color='rgba(97, 114, 176, 1.0)'
accent_color='rgba(46, 125, 233, 1.0)'
accent_ring_color='rgba(46, 125, 233, 0.9)'
error_color='rgba(245, 42, 101, 1.0)' error_color='rgba(245, 42, 101, 1.0)'
field_color='rgba(208, 213, 227, 0.85)' field_color='rgba(208, 213, 227, 0.85)'
dim_hex='6172b0' dim_hex='6172b0'
@@ -131,13 +161,18 @@ load_preferences() {
background_color='rgba(34, 36, 54, 1.0)' background_color='rgba(34, 36, 54, 1.0)'
foreground_color='rgba(200, 211, 245, 1.0)' foreground_color='rgba(200, 211, 245, 1.0)'
dim_color='rgba(130, 139, 184, 1.0)' dim_color='rgba(130, 139, 184, 1.0)'
accent_color='rgba(130, 170, 255, 1.0)'
accent_ring_color='rgba(130, 170, 255, 0.9)'
error_color='rgba(255, 117, 127, 1.0)' error_color='rgba(255, 117, 127, 1.0)'
field_color='rgba(46, 47, 61, 0.85)' field_color='rgba(46, 47, 61, 0.85)'
dim_hex='828bb8' dim_hex='828bb8'
error_hex='ff757f' error_hex='ff757f'
fi fi
# The focus ring is the accent role, not a scheme-relative one: it follows
# accentName the same way ColorScheme.qml's active_border does, and needs
# both the accent and the scheme since each accent carries a separate pair.
accent_rgb="$(hex_to_rgb "$(accent_hex "$accent_name" "$color_scheme")")"
accent_color="rgba($accent_rgb, 1.0)"
accent_ring_color="rgba($accent_rgb, 0.9)"
} }
monitor_names() { monitor_names() {
@@ -16,21 +16,61 @@
# is authoritative for GTK3 applications. Pinned to dark, it contradicted the # is authoritative for GTK3 applications. Pinned to dark, it contradicted the
# scheme in light mode, so it is generated from a template here instead. # scheme in light mode, so it is generated from a template here instead.
# #
# panama-theme-apps dark|light # panama-theme-apps dark|light [accent]
# #
# kitty gets it twice: the generated include file so terminals opened later # kitty gets it twice: the generated include file so terminals opened later
# start correct, and a live `set-colors` over its control socket so terminals # start correct, and a live `set-colors` over its control socket so terminals
# already open change now. Without the second, a scheme change appears to do # already open change now. Without the second, a scheme change appears to do
# nothing until you open a new window. # nothing until you open a new window.
#
# kitty's active_border_color and hyprlock's focus ring are also the accent
# role, not just the scheme -- see accent_hex() below, which mirrors the same
# lookup hypr/looks.lua and scripts/panama-lock carry for the same reason.
set -euo pipefail set -euo pipefail
scheme="${1:-dark}" scheme="${1:-dark}"
case "$scheme" in case "$scheme" in
dark|light) ;; dark|light) ;;
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;; *) printf 'usage: panama-theme-apps [dark|light] [accent]\n' >&2; exit 2 ;;
esac esac
# Falls back to blue for an unknown or omitted name, matching Theme.qml's own
# accentPair fallback -- so a caller that only ever knew about scheme (a stale
# ColorScheme.qml, a manual invocation) still gets a real accent instead of an
# empty string reaching sed.
accent="${2:-blue}"
case "$accent" in
blue|orchid|teal|green|amber|orange|rose|slate) ;;
*) accent=blue ;;
esac
# Same 8 named accents as config/Theme.qml's `accents` map (kept in sync by
# hand -- there is no shared source between QML and a shell script), primary
# hue per scheme only: both consumers below draw a flat colour, not the
# two-stop gradient the window border does.
accent_hex() {
local name="$1" scheme="$2"
case "$name" in
orchid) [[ "$scheme" == light ]] && printf '7847bd' || printf 'c099ff' ;;
teal) [[ "$scheme" == light ]] && printf '007197' || printf '86e1fc' ;;
green) [[ "$scheme" == light ]] && printf '587539' || printf 'c3e88d' ;;
amber) [[ "$scheme" == light ]] && printf '8c6c3e' || printf 'ffc777' ;;
orange) [[ "$scheme" == light ]] && printf 'b15c00' || printf 'ff966c' ;;
rose) [[ "$scheme" == light ]] && printf 'f52a65' || printf 'ff757f' ;;
slate) [[ "$scheme" == light ]] && printf '6172b0' || printf '828bb8' ;;
*) [[ "$scheme" == light ]] && printf '2e7de9' || printf '82aaff' ;;
esac
}
# 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}"
}
accent_border_hex="$(accent_hex "$accent" "$scheme")"
# ── hyprlock ───────────────────────────────────────────────────────────────── # ── hyprlock ─────────────────────────────────────────────────────────────────
# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock || # The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
# hyprlock`), so it reads this file each time and needs no restart. # hyprlock`), so it reads this file each time and needs no restart.
@@ -45,7 +85,6 @@ lock_template="$lock_dir/hyprlock.conf.template"
if [[ "$scheme" == "light" ]]; then if [[ "$scheme" == "light" ]]; then
lock_fg="55, 96, 191" # #3760bf lock_fg="55, 96, 191" # #3760bf
lock_muted="97, 114, 176" # #6172b0 lock_muted="97, 114, 176" # #6172b0
lock_accent="46, 125, 233" # #2e7de9
lock_error="245, 42, 101" # #f52a65 lock_error="245, 42, 101" # #f52a65
lock_bg="225, 226, 231" # #e1e2e7 lock_bg="225, 226, 231" # #e1e2e7
lock_field="208, 213, 227" # #d0d5e3 lock_field="208, 213, 227" # #d0d5e3
@@ -54,7 +93,6 @@ if [[ "$scheme" == "light" ]]; then
else else
lock_fg="200, 211, 245" # #c8d3f5 lock_fg="200, 211, 245" # #c8d3f5
lock_muted="130, 139, 184" # #828bb8 lock_muted="130, 139, 184" # #828bb8
lock_accent="130, 170, 255" # #82aaff
lock_error="255, 117, 127" # #ff757f lock_error="255, 117, 127" # #ff757f
lock_bg="34, 36, 54" # #222436 lock_bg="34, 36, 54" # #222436
lock_field="46, 47, 61" # #2e2f3d lock_field="46, 47, 61" # #2e2f3d
@@ -62,6 +100,10 @@ else
lock_error_hex="ff757f" lock_error_hex="ff757f"
fi fi
# The focus ring is the accent role, not a scheme-relative one -- follows
# accentName the same way scripts/panama-lock's own generator does.
lock_accent="$(hex_to_rgb "$accent_border_hex")"
status_hyprlock="skipped" status_hyprlock="skipped"
if [[ -r "$lock_template" ]]; then if [[ -r "$lock_template" ]]; then
# Written atomically: a lock triggered mid-write would otherwise read a # Written atomically: a lock triggered mid-write would otherwise read a
@@ -169,9 +211,16 @@ theme_file="$kitty_dir/themes/tokyonight-moon.conf"
status_kitty="skipped" status_kitty="skipped"
if [[ -r "$theme_file" ]]; then if [[ -r "$theme_file" ]]; then
# Written atomically: kitty may read this while a new window is starting. # Written atomically: kitty may read this while a new window is starting.
# The accent override is appended after the copied theme rather than
# edited into the tracked theme file: kitty applies settings top to
# bottom, so a later active_border_color line wins, and this file is
# itself generated -- the tracked per-scheme theme stays scheme-only.
if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \ if cp "$theme_file" "$kitty_dir/current-theme.conf.tmp" 2>/dev/null \
&& printf 'active_border_color #%s\n' "$accent_border_hex" >>"$kitty_dir/current-theme.conf.tmp" \
&& mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then && mv "$kitty_dir/current-theme.conf.tmp" "$kitty_dir/current-theme.conf" 2>/dev/null; then
status_kitty="written" status_kitty="written"
else
rm -f "$kitty_dir/current-theme.conf.tmp"
fi fi
# Live-apply to running terminals. kitty appends its PID to the socket name # Live-apply to running terminals. kitty appends its PID to the socket name
@@ -182,7 +231,8 @@ if [[ -r "$theme_file" ]]; then
applied=0 applied=0
while read -r socket; do while read -r socket; do
[[ -n "$socket" ]] || continue [[ -n "$socket" ]] || continue
if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1; then if kitty @ --to "unix:@${socket#@}" set-colors --all --configured "$theme_file" >/dev/null 2>&1 \
&& kitty @ --to "unix:@${socket#@}" set-colors --override "active_border_color=#$accent_border_hex" >/dev/null 2>&1; then
applied=$((applied + 1)) applied=$((applied + 1))
fi fi
done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u) done < <(ss -xl 2>/dev/null | grep -oE '@mykitty[^[:space:]]*' | sort -u)
+90 -45
View File
@@ -34,13 +34,23 @@ Singleton {
// for the Prism gradient; Theme owns which colours, this owns the form the // for the Prism gradient; Theme owns which colours, this owns the form the
// compositor wants them in. // compositor wants them in.
function hyprColor(value: var): string { function hyprColor(value: var): string {
// Qt gives "#rrggbb"; Hyprland wants rgba(rrggbbaa). // Qt gives "#rrggbb" -- or "#aarrggbb" if the colour ever carries an
return "rgba(" + String(value).replace("#", "").slice(0, 6) + "ee)"; // alpha channel. slice(-6) keeps the trailing rrggbb either way;
// slice(0, 6) would instead grab "aarrgg" out of an 8-digit string and
// call it RGB. Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(-6) + "ee)";
} }
readonly property string accentBorderStart: root.hyprColor(Theme.accent) readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary) readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
property string lastError: "" property string lastError: ""
// What the last push actually sent, so apply() can tell an accent-only
// change apart from a scheme change and skip the steps that do not depend
// on whichever did not move. Their starting values do not matter: the
// first apply() always runs with force set, which ignores both.
property bool appliedDark: false
property string appliedAccentName: ""
// Applied one command at a time: Process runs a single command, and several // Applied one command at a time: Process runs a single command, and several
// of these are separate programs. // of these are separate programs.
property var pending: [] property var pending: []
@@ -75,7 +85,7 @@ Singleton {
Timer { Timer {
id: settle id: settle
interval: 1200 interval: 1200
onTriggered: root.apply() onTriggered: root.apply(true)
} }
Connections { Connections {
@@ -86,57 +96,92 @@ Singleton {
Timer { Timer {
id: coalesce id: coalesce
interval: 250 interval: 250
onTriggered: root.apply() onTriggered: root.apply(false)
} }
function apply(): void { // `force` pushes every step regardless of what moved -- startup needs
// that, because gsettings and the compositor keep their own state and
// have no way to know a previous Quickshell session already told them the
// answer. Everywhere else this reacts to ANY preference changing (see
// onRevisionChanged above), so most calls have nothing to do with either
// scheme or accent; only the steps whose inputs actually moved since the
// last push run, which keeps sampling accent swatches from also rewriting
// gsettings and re-running the whole app-theming script on every sample.
function apply(force: bool): void {
root.lastError = ""; root.lastError = "";
const scheme = root.dark ? "prefer-dark" : "prefer-light"; const accentName = DesktopPreferences.get("accentName") || "blue";
const schemeChanged = force || root.dark !== root.appliedDark;
const accentChanged = force || accentName !== root.appliedAccentName;
// adw-gtk3, not Adwaita. This is the bug that made dark mode look if (!schemeChanged && !accentChanged)
// broken while light mode looked fine: return;
//
// Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
// 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
// not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
const commands = [ const commands = [];
["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme],
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
// Unfocused window borders need scheme-relative contrast, and stay this if (schemeChanged) {
// service's to own. The FOCUSED border is the accent role and belongs to const scheme = root.dark ? "prefer-dark" : "prefer-light";
// the theme -- see modules/settings/README.md -- so it is written from
// the chosen accent rather than from the scheme.
//
// Both are pushed together because both change when the scheme flips:
// each accent carries a separate pair for light and dark, so switching
// schemes must restate the focused border too, not only the neutral one.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
// A two-stop gradient at the shipped angle. Written as a Lua TABLE: the // adw-gtk3, not Adwaita. This is the bug that made dark mode look
// string form of a gradient carries only one stop, and passing // broken while light mode looked fine:
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently keeps //
// the previous value. // Neither "Adwaita" nor "Adwaita-dark" is an installed theme on Fedora
commands.push(["hyprctl", "eval", // 44 -- only adw-gtk3 and adw-gtk3-dark are. Naming a theme that does
`hl.config({ general = { col = { active_border = { colors = { "${root.accentBorderStart}", "${root.accentBorderEnd}" }, angle = 115 } } } })`]); // not exist makes GTK fall back to its built-in default, which is
// LIGHT. So asking for light accidentally worked, asking for dark
// silently produced light, and applications that take their cue from
// the GTK theme rather than the portal -- Chromium and Electron, when
// built against GTK -- stayed light no matter what the portal said.
//
// gtk-theme-contract asserts these names are actually installed,
// because the failure mode is silent in exactly this way.
const gtkTheme = root.dark ? "adw-gtk3-dark" : "adw-gtk3";
// Applications that predate org.freedesktop.appearance and carry their commands.push(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", scheme]);
// own palettes -- terminals, chiefly. Everything that reads the portal commands.push(["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]);
// (GTK4, Qt6, Chromium, Electron) is already handled by the gsettings
// write above and needs nothing here. // Unfocused window borders need scheme-relative contrast, and stay
commands.push([root.appThemePath, root.dark ? "dark" : "light"]); // this service's to own. The FOCUSED border below is the accent
// role and belongs to the theme -- see modules/settings/README.md.
// Unlike that role, this one does not depend on the accent, so an
// accent-only change never needs to restate it.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]);
}
if (schemeChanged || accentChanged) {
// Each accent carries a separate pair for light and dark, so this
// runs on either kind of change: a scheme flip restates the same
// accent's other pair, and an accent change restates the same
// scheme's other colours.
//
// A two-stop gradient at the shipped angle. Written as a Lua TABLE:
// the string form of a gradient carries only one stop, and passing
// "rgba(a) rgba(b) 115deg" as a string is accepted and silently
// keeps the previous value. Multi-stop must be the table form --
// built by the same serialiseValue() SystemSettings uses for every
// other gradient, rather than a second hand-rolled copy of that
// escaping here.
const activeBorder = SystemSettings.serialiseValue({
colors: [root.accentBorderStart, root.accentBorderEnd],
angle: 115
});
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = ${activeBorder} } } })`]);
// Applications that predate org.freedesktop.appearance and carry
// their own palettes -- terminals, chiefly. Everything that reads
// the portal (GTK4, Qt6, Chromium, Electron) is already handled by
// the gsettings write above and needs nothing here. The accent is
// passed through too: kitty's border and the hyprlock fallback
// template are also the accent role, not just the scheme -- so
// this still has to run on an accent-only change, even though the
// scheme-relative work it also does is then redundant.
commands.push([root.appThemePath, root.dark ? "dark" : "light", accentName]);
}
root.appliedDark = root.dark;
root.appliedAccentName = accentName;
root.enqueue(commands); root.enqueue(commands);
} }
@@ -26,6 +26,7 @@ Singleton {
// entry here still appears in results and routes to Home rather than being // entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable. // dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({ readonly property var groupPages: ({
"appearance": "appearance",
"clock": "appearance", "clock": "appearance",
"vitals": "appearance", "vitals": "appearance",
"typography": "appearance", "typography": "appearance",
@@ -69,7 +69,7 @@ assert_schema_entry() {
} }
[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable' [[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable'
for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty accentName; do
assert_schema_entry "$key" assert_schema_entry "$key"
done done
@@ -112,6 +112,25 @@ if rg -q '^label \{' "$generated"; then
fail 'hidden clock, date, and user labels were still generated' fail 'hidden clock, date, and user labels were still generated'
fi fi
# ── The focus ring follows the chosen accent, not just the scheme ───────────
# A pinned blue literal only ever proves the DEFAULT accent renders correctly,
# not that the helper actually reads accentName. Orchid is picked because its
# hex is nowhere close to blue's in either scheme, so a helper that quietly
# ignored accentName and kept emitting blue would be caught here.
write_settings '{"accentName":"orchid","colorScheme":"dark"}'
run_helper generate
rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success colour'
write_settings '{"accentName":"orchid","colorScheme":"light"}'
run_helper generate
rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success colour'
write_settings '{"accentName":"not-a-real-accent","colorScheme":"dark"}'
run_helper generate
rg -Fq 'outer_color = rgba(130, 170, 255, 0.9)' "$generated" || fail 'an unknown accent name did not fall back to blue'
write_settings '{ write_settings '{
"lockBackgroundMode":"wallpaper", "lockBackgroundMode":"wallpaper",
"wallpaperMode":"per-monitor", "wallpaperMode":"per-monitor",
@@ -2,7 +2,9 @@
# A setting has one schema-routed owner. A second page may mirror it only when # A setting has one schema-routed owner. A second page may mirror it only when
# this contract names the exact owner and mirror set. The same ownership rule # this contract names the exact owner and mirror set. The same ownership rule
# keeps colour scheme propagation away from the focused Prism border. # says who writes each half of the window border: ColorScheme.qml owns the
# neutral inactive role AND the accent-derived focused role, restating both
# together on every scheme or accent change.
set -euo pipefail set -euo pipefail
@@ -124,8 +126,28 @@ if dark.group(1) not in looks or light.group(1) not in looks:
raise SystemExit("Hyprland startup values disagree with the live scheme roles") raise SystemExit("Hyprland startup values disagree with the live scheme roles")
without_comments = re.sub(r"//.*", "", scheme) without_comments = re.sub(r"//.*", "", scheme)
if re.search(r"(?<![A-Za-z_])active_border\b", without_comments):
raise SystemExit("ColorScheme writes the focused border") # The focused border is the accent role, and ColorScheme.qml owns it too:
# each named accent carries a separate pair per scheme, so a scheme change
# must restate the focused border, not just the neutral one, or a chosen
# accent goes stale the moment light/dark flips.
start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent\)', scheme)
end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary\)', scheme)
if not start or not end:
raise SystemExit("ColorScheme does not derive the focused border from the chosen accent")
# Written as a Lua TABLE, not a string: the string form of a Hyprland gradient
# carries only one stop, so writing it that way is accepted and silently
# keeps whatever the previous accent left behind. Built through the same
# serialiseValue() SystemSettings.qml uses for every other gradient, rather
# than a second hand-rolled (and unescaped) copy of that table syntax here.
if 'SystemSettings.serialiseValue({' not in without_comments:
raise SystemExit("ColorScheme does not build the focused border through the shared gradient serialiser")
if 'colors: [root.accentBorderStart, root.accentBorderEnd]' not in without_comments:
raise SystemExit("ColorScheme does not derive the focused-border gradient from the chosen accent")
if 'active_border = ${activeBorder}' not in without_comments:
raise SystemExit("ColorScheme does not write the focused border as the serialised gradient table")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme: if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role") raise SystemExit("ColorScheme does not apply its effective inactive role")
PY PY