Author SHA1 Message Date
Gabriel Brown c37ca3baee Make the accent colour choosable
Phase 4, first slice. Theme.qml hardcoded the Prism pair, so the one
thing that carries every state meaning in the desktop -- focused, active,
on -- was the one thing nobody could change. 74 files read Theme.accent,
so making it a setting moves all of them at once.

Named accents rather than a colour picker, which is the design decision
worth defending. One hex cannot serve both schemes: a colour legible on
the Moon background is usually illegible on the Day one, and a picker
that lets someone build an unreadable desktop is not a feature. So each
name carries a curated pair per scheme, and every one of the sixteen
resulting colours measures at least 3:1 against the ground it sits on --
checked, not assumed. It is also GNOME's model, which is the parity
being chased.

The focused window border comes with it, and only because the ownership
rule made that safe. ColorScheme owns the inactive border as a
scheme-relative contrast role; the focused Prism border is the accent
role owned by the theme. Writing it from the accent would have been
reckless before that boundary existed, since a scheme change would have
erased the user's choice. Both borders are now pushed together, because
each accent carries separate light and dark pairs, so switching schemes
must restate the focused border too.

The gradient is written as a Lua table, not a string. The string form
carries only one stop, and passing two as a string is accepted and
silently keeps the previous value.

Swatches are drawn as the gradient they produce rather than as flat
dots, because the gradient is what is being chosen. Each carries its
name permanently rather than in a tooltip: telling swatches apart by
colour is precisely what someone with a colour vision deficiency cannot
do, which is also why the palette is named in the first place.

Verified end to end by switching to rose and watching the compositor
report eeff757f/eec099ff, then reverting.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 17:02:18 -04:00
Gabriel Brown bd5d030d91 Put power profiles and the colour scheme in the Control Center
The panel is for "change it now" decisions, and two of the most obvious
were reachable only through Settings. Night Light was already here;
these were the genuine gaps.

The colour scheme is a grid toggle beside it. It writes the preference
and stops -- ColorScheme propagates the change to GTK, the portal, the
terminals, the launcher, btop, tmux and the lock screen, and nothing in
the panel needs to know that list. Light is the lit state because dark
is what Panama ships, and "active" reads as the non-default everywhere
else in this grid.

Power profiles are a summary row that expands into a list, matching how
the audio device lists behave. A row per profile rather than a cycling
button: there are three, and cycling passes through one you did not want
on a machine where the change is immediate and audible. The row hides
entirely where no power-profiles daemon is running, and the panel
re-reads the active profile on open, because the daemon owns it and
anything on the system can change it.

Worth recording, because it invalidates something I believed earlier in
this session: the live shell runs `quickshell --daemonize` and does NOT
hot-reload. Editing a file under config/dot/quickshell changes nothing
until the shell is restarted. The PID changes I had taken for hot
reloads were tests killing and restarting it. Both of these controls
were written, verified in a harness, and completely absent from the
running panel until the shell was restarted.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 16:25:59 -04:00
10 changed files with 280 additions and 10 deletions
@@ -871,6 +871,26 @@ Singleton {
{ value: "light", label: "Light" }
]
},
{
key: "accentName", type: "enum", def: "blue", group: "appearance",
label: "Accent colour",
detail: "Drives the focused window border, the bar hairline, and every active state",
// NAMED accents, not a free colour. Each name carries a curated
// pair per scheme, because one hex cannot serve both: a colour
// legible on the dark ground is usually illegible on the light one.
// The palette and its measured contrast live in config/Theme.qml,
// which is also what stops this list drifting from what is drawn.
options: [
{ value: "blue", label: "Prism blue" },
{ value: "orchid", label: "Orchid" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Green" },
{ value: "amber", label: "Amber" },
{ value: "orange", label: "Orange" },
{ value: "rose", label: "Rose" },
{ value: "slate", label: "Slate" }
]
},
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
+35 -7
View File
@@ -42,13 +42,41 @@ Singleton {
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5"
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb"
// The pair. `accent` is the primary and carries every state meaning
// (focused, active, on). `accentSecondary` is the orchid from the tmux
// theme — it never appears alone, only as the far end of a gradient. That
// restraint is the whole point: the two colours meeting is the signature,
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux
// ── The accent ──────────────────────────────────────────────────────────
//
// `accent` is the primary and carries every state meaning (focused, active,
// on). `accentSecondary` never appears alone, only as the far end of a
// gradient. That restraint is the whole point: the two colours meeting is
// the signature, so the second colour stops being special the moment it is
// used as a flat fill.
//
// NAMED accents rather than a free colour. Each name carries a curated
// triple per scheme, because an arbitrary hex cannot work in both: a colour
// legible on the Moon background is usually illegible on the Day one, and a
// picker that lets someone choose an unreadable desktop is not a feature.
// Every pair below measures at least 3:1 against the ground it sits on.
// This is also GNOME's model, which is the parity being chased.
//
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
// the default.
readonly property var accents: ({
"blue": { dark: "#82aaff", darkSecondary: "#b172b0", light: "#2e7de9", lightSecondary: "#9854f1", label: "Prism blue" },
"orchid": { dark: "#c099ff", darkSecondary: "#fca7ea", light: "#7847bd", lightSecondary: "#9854f1", label: "Orchid" },
"teal": { dark: "#86e1fc", darkSecondary: "#82aaff", light: "#007197", lightSecondary: "#2e7de9", label: "Teal" },
"green": { dark: "#c3e88d", darkSecondary: "#86e1fc", light: "#587539", lightSecondary: "#007197", label: "Green" },
"amber": { dark: "#ffc777", darkSecondary: "#ff966c", light: "#8c6c3e", lightSecondary: "#b15c00", label: "Amber" },
"orange": { dark: "#ff966c", darkSecondary: "#ff757f", light: "#b15c00", lightSecondary: "#c64343", label: "Orange" },
"rose": { dark: "#ff757f", darkSecondary: "#c099ff", light: "#f52a65", lightSecondary: "#9854f1", label: "Rose" },
"slate": { dark: "#828bb8", darkSecondary: "#82aaff", light: "#6172b0", lightSecondary: "#2e7de9", label: "Slate" }
})
// Falls back to blue for an unknown name, so a settings file written by a
// newer Panama -- or edited by hand -- degrades to the shipped identity
// rather than to an undefined colour.
readonly property var accentPair: root.accents[DesktopPreferences.get("accentName")] ?? root.accents["blue"]
readonly property color accent: root.dark ? root.accentPair.dark : root.accentPair.light
readonly property color accentSecondary: root.dark ? root.accentPair.darkSecondary : root.accentPair.lightSecondary
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue
readonly property color cyan: root.dark ? "#86e1fc" : "#007197"
readonly property color teal: root.dark ? "#4fd6be" : "#118c74"
@@ -0,0 +1,57 @@
// The three system power profiles, as an expandable list.
//
// A row per profile rather than a cycling button: there are three, and cycling
// through them means passing through one you did not want on a machine where
// the change is immediate and audible. The same shape the audio device lists
// use, so the panel reads consistently.
//
// The daemon owns the profile -- it survives a shell restart and anything else
// on the system can change it -- so this reads back rather than assuming, the
// same as monitor brightness.
import QtQuick
import qs.config
import qs.services
Column {
id: root
spacing: 2
Repeater {
model: PowerProfiles.profiles
RowButton {
required property var modelData
width: root.width
icon: {
switch (modelData) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
iconFallback: "preferences-system-power-symbolic"
label: PowerProfiles.label(modelData)
sublabel: PowerProfiles.detail(modelData)
selected: modelData === PowerProfiles.active
dimmed: PowerProfiles.busy
onClicked: PowerProfiles.set(modelData)
}
}
// Only when it is true. A machine that cannot deliver the profile it is set
// to is the one case where the label alone is misleading.
Text {
visible: PowerProfiles.degraded !== ""
width: root.width
text: "Limited right now: " + PowerProfiles.degraded
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
leftPadding: 10
topPadding: 4
}
}
@@ -45,6 +45,9 @@ PanelWindow {
if (root.visible) {
KdeConnect.refresh();
HomeAssistant.refresh();
// The daemon owns the power profile and anything on the system can
// change it, so the panel asks rather than trusting what it last saw.
PowerProfiles.refresh();
openAnim.restart();
} else {
panel.reset();
@@ -130,6 +130,21 @@ Item {
onToggled: Caffeine.toggle()
}
Toggle {
width: root.cellWidth
icon: ColorScheme.dark ? "weather-clear-night-symbolic" : "weather-clear-symbolic"
label: "Appearance"
sublabel: ColorScheme.dark ? "Dark" : "Light"
// "active" reads as the non-default state across this grid, and
// dark is what Panama ships, so light is the lit one.
active: !ColorScheme.dark
// Writes the preference and stops. ColorScheme propagates it to
// GTK, the portal, the terminals, the launcher, btop, tmux and
// the lock screen; nothing here needs to know that list.
onToggled: SystemSettings.commitPreference("colorScheme",
ColorScheme.dark ? "light" : "dark")
}
Toggle {
width: root.cellWidth
icon: "night-light-symbolic"
@@ -190,6 +205,38 @@ Item {
color: Theme.alpha(Theme.fg, 0.1)
}
// ── Power ───────────────────────────────────────────────────────────
// Only where a power-profiles daemon is actually running. A desktop
// without one should not show a control that cannot do anything.
RowButton {
visible: PowerProfiles.available
width: content.width
icon: {
switch (PowerProfiles.active) {
case "power-saver": return "power-profile-power-saver-symbolic";
case "performance": return "power-profile-performance-symbolic";
default: return "power-profile-balanced-symbolic";
}
}
iconFallback: "preferences-system-power-symbolic"
label: "Power profile"
sublabel: PowerProfiles.degraded !== ""
? PowerProfiles.label(PowerProfiles.active) + " · limited"
: PowerProfiles.label(PowerProfiles.active)
selected: root.expandedSection === "power"
onClicked: root.expand("power")
}
Section {
width: content.width
expanded: root.expandedSection === "power"
PowerProfileList {
anchors.left: parent.left
anchors.right: parent.right
}
}
// ── Sliders ─────────────────────────────────────────────────────────
AudioSlider {
width: content.width
@@ -17,3 +17,4 @@ RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml
WifiList 1.0 WifiList.qml
PowerProfileList 1.0 PowerProfileList.qml
@@ -0,0 +1,84 @@
// Choosing the desktop accent.
//
// Each swatch is drawn as the GRADIENT it will actually produce, not a flat
// dot, because the gradient is the thing being chosen -- the focused window
// border, the bar hairline and every active state are the two colours meeting.
// A row of flat circles would misrepresent all of them.
//
// Named accents rather than a colour wheel: each name carries a curated pair
// per scheme, so every choice stays legible in both light and dark. See
// config/Theme.qml for the palette and the reasoning.
import QtQuick
import qs.config
import qs.services
Flow {
id: root
spacing: 10
readonly property string current: DesktopPreferences.get("accentName") || "blue"
Repeater {
// Object key order is insertion order here, so the palette's own order
// is what the user sees; blue first because it is what Panama ships.
model: Object.keys(Theme.accents)
Column {
id: entry
required property var modelData
readonly property var pair: Theme.accents[entry.modelData]
readonly property bool selected: entry.modelData === root.current
readonly property color start: Theme.dark ? entry.pair.dark : entry.pair.light
readonly property color end: Theme.dark ? entry.pair.darkSecondary : entry.pair.lightSecondary
spacing: 5
Rectangle {
width: 46
height: 46
radius: 23
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
// The ring sits outside the gradient rather than over it, so a
// selected swatch still shows its true colours.
border.width: entry.selected ? 2 : 1
border.color: entry.selected ? Theme.fg : Theme.alpha(Theme.fg, 0.14)
Rectangle {
anchors.fill: parent
anchors.margins: entry.selected ? 4 : 3
radius: width / 2
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: entry.start }
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
// exactly what someone with a colour vision deficiency cannot do,
// and it is the reason the palette is named rather than freeform --
// hiding the names behind a hover would waste that.
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: entry.pair.label
color: entry.selected ? Theme.fg : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: entry.selected ? Font.DemiBold : Font.Normal
}
}
}
}
@@ -106,7 +106,13 @@ SettingsPage {
? ColorScheme.lastError
: "Light is Tokyo Night Day, the official light variant — the same hues at a different lightness, so the blue-into-orchid signature survives the switch. Applications and window borders follow."
ChoiceRow { setting: "colorScheme"; divider: false }
ChoiceRow { setting: "colorScheme" }
// Drawn as the gradient each accent produces rather than a flat dot,
// because the gradient is what is being chosen.
AccentPicker {
width: parent.width
}
}
SettingsCard {
@@ -61,3 +61,4 @@ PrivacyPage 1.0 PrivacyPage.qml
RegionPage 1.0 RegionPage.qml
SearchPicker 1.0 SearchPicker.qml
OnlineAccountsPage 1.0 OnlineAccountsPage.qml
AccentPicker 1.0 AccentPicker.qml
+25 -2
View File
@@ -29,6 +29,16 @@ Singleton {
readonly property string inactiveBorderDark: "rgba(3b426199)"
readonly property string inactiveBorderLight: "rgba(a8aecb99)"
readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight
// The focused border follows the chosen accent. `ee` is the shipped alpha
// for the Prism gradient; Theme owns which colours, this owns the form the
// compositor wants them in.
function hyprColor(value: var): string {
// Qt gives "#rrggbb"; Hyprland wants rgba(rrggbbaa).
return "rgba(" + String(value).replace("#", "").slice(0, 6) + "ee)";
}
readonly property string accentBorderStart: root.hyprColor(Theme.accent)
readonly property string accentBorderEnd: root.hyprColor(Theme.accentSecondary)
property string lastError: ""
// Applied one command at a time: Process runs a single command, and several
@@ -104,11 +114,24 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
];
// Unfocused window borders need scheme-relative contrast. The focused
// Prism border is deliberately owned by the accent/theme layer.
// Unfocused window borders need scheme-relative contrast, and stay this
// service's to own. The FOCUSED border is the accent role and belongs to
// 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
// 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.
commands.push(["hyprctl", "eval",
`hl.config({ general = { col = { active_border = { colors = { "${root.accentBorderStart}", "${root.accentBorderEnd}" }, angle = 115 } } } })`]);
// 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