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
This commit is contained in:
Gabriel Brown
2026-08-23 23:39:04 -04:00
parent 7578348db1
commit cb7c09d208
68 changed files with 6115 additions and 1138 deletions
@@ -1,5 +1,34 @@
var MAX_NAME_LENGTH = 40;
// The token sets a theme record may carry beyond its accent pair. PALETTE_KEYS
// mirrors Theme.qml's color tokens; ANSI_KEYS feed generated terminal themes;
// EFFECT_SPEC mirrors the ten effects schema keys so a custom theme can
// snapshot them. All three are pinned by theme-catalog-contract against
// config/themes.json.
var PALETTE_KEYS = ["bg", "bgDark", "bgHighlight", "bgPanel", "bgPopover",
"fg", "fgDim", "fgMuted", "gutter", "accentAlt",
"cyan", "teal", "green", "yellow", "orange", "red", "redDeep",
"magenta", "pink"];
var ANSI_KEYS = ["black", "red", "green", "yellow", "blue", "magenta", "cyan",
"white", "brightBlack", "brightRed", "brightGreen", "brightYellow",
"brightBlue", "brightMagenta", "brightCyan", "brightWhite"];
var EFFECT_SPEC = {
blurEnabled: { kind: "bool" },
blurSize: { kind: "int", min: 1, max: 20 },
blurPasses: { kind: "int", min: 1, max: 5 },
shadowEnabled: { kind: "bool" },
shadowRange: { kind: "int", min: 0, max: 60 },
shadowSharp: { kind: "bool" },
shadowRenderPower: { kind: "int", min: 1, max: 4 },
glowEnabled: { kind: "bool" },
glowRange: { kind: "int", min: 0, max: 30 },
animationsEnabled: { kind: "bool" }
};
// The minimal built-in shipped list. The full catalog (config/themes.json,
// via ThemeCatalog) is passed into every function that takes a `shipped`
// argument; this fallback keeps the model usable headless and before the
// catalog loads.
var SHIPPED = [
{
id: "moon",
@@ -85,8 +114,53 @@ var CURATED = {
}
};
function copyColorMap(value, keys) {
if (!value || typeof value !== "object")
return null;
var result = {};
for (var index = 0; index < keys.length; index++) {
var color = normalizedColor(value[keys[index]]);
if (!color)
return null;
result[keys[index]] = color;
}
return result;
}
// A palette or ansi block is all-or-nothing: a record either carries every
// key valid, or the field is dropped and resolution falls back to the
// scheme's default theme. Effects are per-key: invalid entries are dropped.
function normalizePalette(value) { return copyColorMap(value, PALETTE_KEYS); }
function normalizeAnsi(value) { return copyColorMap(value, ANSI_KEYS); }
function normalizeEffects(value) {
if (!value || typeof value !== "object")
return null;
var result = {};
var any = false;
Object.keys(EFFECT_SPEC).forEach(function(key) {
var spec = EFFECT_SPEC[key];
var raw = value[key];
if (raw === undefined)
return;
if (spec.kind === "bool") {
if (typeof raw !== "boolean")
return;
result[key] = raw;
any = true;
return;
}
var number = Number(raw);
if (!isFinite(number))
return;
result[key] = Math.round(clamp(number, spec.min, spec.max));
any = true;
});
return any ? result : null;
}
function copyProfile(profile) {
return {
var result = {
id: profile.id,
name: profile.name,
scheme: profile.scheme,
@@ -94,10 +168,24 @@ function copyProfile(profile) {
secondary: profile.secondary,
shipped: profile.shipped === true
};
var palette = normalizePalette(profile.palette);
if (palette)
result.palette = palette;
var ansi = normalizeAnsi(profile.ansi);
if (ansi)
result.ansi = ansi;
var effects = normalizeEffects(profile.effects);
if (effects)
result.effects = effects;
return result;
}
function shippedProfiles() {
return SHIPPED.map(copyProfile);
function shippedList(shipped) {
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
}
function shippedProfiles(shipped) {
return shippedList(shipped).map(copyProfile);
}
function curatedAccents() {
@@ -130,7 +218,7 @@ function normalizeStoredProfile(value) {
|| !isScheme(value.scheme) || !accent || !secondary)
return null;
return {
var result = {
id: id,
name: name,
scheme: value.scheme,
@@ -138,15 +226,25 @@ function normalizeStoredProfile(value) {
secondary: secondary,
shipped: false
};
var palette = normalizePalette(value.palette);
if (palette)
result.palette = palette;
var ansi = normalizeAnsi(value.ansi);
if (ansi)
result.ansi = ansi;
var effects = normalizeEffects(value.effects);
if (effects)
result.effects = effects;
return result;
}
function validCustomProfiles(values) {
function validCustomProfiles(values, shipped) {
if (!Array.isArray(values))
return [];
var ids = {};
var names = {};
SHIPPED.forEach(function(profile) {
shippedList(shipped).forEach(function(profile) {
ids[profile.id] = true;
names[profile.name.toLowerCase()] = true;
});
@@ -166,8 +264,8 @@ function validCustomProfiles(values) {
return result;
}
function profileCatalog(values) {
return shippedProfiles().concat(validCustomProfiles(values));
function profileCatalog(values, shipped) {
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
}
function boundedName(value) {
@@ -218,9 +316,9 @@ function uniqueId(name, profiles) {
return base.slice(0, 52) + "-10000";
}
function createCustomProfile(values, input) {
var customs = validCustomProfiles(values);
var catalog = shippedProfiles().concat(customs);
function createCustomProfile(values, input, shipped) {
var customs = validCustomProfiles(values, shipped);
var catalog = shippedProfiles(shipped).concat(customs);
var scheme = input && input.scheme;
var accent = normalizedColor(input && input.accent);
var secondary = normalizedColor(input && input.secondary);
@@ -236,11 +334,20 @@ function createCustomProfile(values, input) {
secondary: secondary,
shipped: false
};
var palette = normalizePalette(input && input.palette);
if (palette)
profile.palette = palette;
var ansi = normalizeAnsi(input && input.ansi);
if (ansi)
profile.ansi = ansi;
var effects = normalizeEffects(input && input.effects);
if (effects)
profile.effects = effects;
return { profiles: customs.concat([profile]), profile: profile };
}
function findProfile(values, id) {
var catalog = profileCatalog(values);
function findProfile(values, id, shipped) {
var catalog = profileCatalog(values, shipped);
for (var index = 0; index < catalog.length; index++) {
if (catalog[index].id === id)
return catalog[index];
@@ -248,26 +355,32 @@ function findProfile(values, id) {
return null;
}
function editProfile(values, selected, changes) {
var customs = validCustomProfiles(values);
function editProfile(values, selected, changes, shipped) {
var customs = validCustomProfiles(values, shipped);
if (!selected || typeof selected !== "object")
return { profiles: customs, profile: null };
var accent = normalizedColor(changes && changes.accent !== undefined
? changes.accent : selected.accent);
var secondary = normalizedColor(changes && changes.secondary !== undefined
? changes.secondary : selected.secondary);
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
function pick(field, fallback) {
return changes && changes[field] !== undefined ? changes[field] : fallback;
}
var accent = normalizedColor(pick("accent", selected.accent));
var secondary = normalizedColor(pick("secondary", selected.secondary));
var scheme = pick("scheme", selected.scheme);
if (!isScheme(scheme) || !accent || !secondary)
return { profiles: customs, profile: null };
if (selected.shipped === true) {
// Editing a shipped theme forks it into a custom, carrying the whole
// palette so the fork looks identical until the edit lands.
return createCustomProfile(customs, {
name: selected.name + " custom",
scheme: scheme,
accent: accent,
secondary: secondary
});
secondary: secondary,
palette: pick("palette", selected.palette),
ansi: pick("ansi", selected.ansi),
effects: pick("effects", selected.effects)
}, shipped);
}
var stored = normalizeStoredProfile(selected);
@@ -279,6 +392,21 @@ function editProfile(values, selected, changes) {
accent: accent,
secondary: secondary
});
var palette = normalizePalette(pick("palette", stored.palette));
if (palette)
updated.palette = palette;
else
delete updated.palette;
var ansi = normalizeAnsi(pick("ansi", stored.ansi));
if (ansi)
updated.ansi = ansi;
else
delete updated.ansi;
var effects = normalizeEffects(pick("effects", stored.effects));
if (effects)
updated.effects = effects;
else
delete updated.effects;
var found = false;
var next = customs.map(function(profile) {
if (profile.id !== updated.id)
@@ -291,10 +419,11 @@ function editProfile(values, selected, changes) {
return { profiles: next, profile: updated };
}
function deleteProfile(values, id) {
var customs = validCustomProfiles(values);
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
if (SHIPPED[shippedIndex].id === id)
function deleteProfile(values, id, shipped) {
var customs = validCustomProfiles(values, shipped);
var fixed = shippedList(shipped);
for (var shippedIndex = 0; shippedIndex < fixed.length; shippedIndex++) {
if (fixed[shippedIndex].id === id)
return { profiles: customs, removed: false };
}
var next = customs.filter(function(profile) { return profile.id !== id; });
@@ -308,17 +437,131 @@ function curatedPair(name, scheme) {
return { accent: entry.dark, secondary: entry.darkSecondary };
}
function matchingShippedProfile(scheme, accent, secondary) {
function matchingShippedProfile(scheme, accent, secondary, shipped) {
var first = normalizedColor(accent);
var second = normalizedColor(secondary);
for (var index = 0; index < SHIPPED.length; index++) {
var profile = SHIPPED[index];
var fixed = shippedList(shipped);
for (var index = 0; index < fixed.length; index++) {
var profile = fixed[index];
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
return copyProfile(profile);
}
return null;
}
// The nearest curated accent to an arbitrary pair, by hue distance of the
// primary. This is what keeps `accentName` — and through it GNOME's
// accent-color enum, kitty's border, and the lock screen — in sync when a
// theme or a custom color is chosen instead of a curated swatch.
function nearestCuratedName(scheme, accent) {
var target = hexToHsv(accent);
if (!target)
return "blue";
var bestName = "blue";
var bestDistance = Infinity;
Object.keys(CURATED).forEach(function(name) {
var pair = curatedPair(name, isScheme(scheme) ? scheme : "dark");
var candidate = hexToHsv(pair.accent);
if (!candidate)
return;
var delta = Math.abs(candidate.h - target.h);
var hueDistance = Math.min(delta, 360 - delta);
// A desaturated accent should map to slate rather than whichever hue
// its noise happens to lean toward.
var distance = target.s < 12
? (name === "slate" ? 0 : 1000 + hueDistance)
: hueDistance + Math.abs(candidate.s - target.s) * 0.15;
if (distance < bestDistance) {
bestDistance = distance;
bestName = name;
}
});
return bestName;
}
// ── Palette derivation, for the editor ──────────────────────────────────────
function mixHex(first, second, ratio) {
var a = normalizedColor(first);
var b = normalizedColor(second);
var r = clamp(ratio, 0, 1);
if (!a || !b)
return a || b;
function channel(offset) {
var from = parseInt(a.slice(offset, offset + 2), 16);
var to = parseInt(b.slice(offset, offset + 2), 16);
return channelHex(from + (to - from) * r);
}
return "#" + channel(1) + channel(3) + channel(5);
}
// A full palette from the four editor wells plus a base theme. Background
// derives the surface family, foreground derives the text tints, and every
// other token is inherited from the base so a small edit stays a small edit.
function derivePalette(base, input) {
var origin = normalizePalette(base) || null;
var bg = normalizedColor(input && input.bg) || (origin ? origin.bg : null);
var fg = normalizedColor(input && input.fg) || (origin ? origin.fg : null);
var accent = normalizedColor(input && input.accent);
var scheme = isScheme(input && input.scheme) ? input.scheme : "dark";
if (!bg || !fg)
return origin;
var dark = scheme === "dark";
var result = origin ? Object.assign({}, origin) : {};
result.bg = bg;
result.bgDark = mixHex(bg, "#000000", dark ? 0.10 : 0.06);
result.bgPopover = dark ? mixHex(bg, "#000000", 0.06) : mixHex(bg, "#ffffff", 0.35);
result.bgPanel = dark ? mixHex(bg, fg, 0.05) : mixHex(bg, "#000000", 0.03);
result.bgHighlight = mixHex(bg, fg, dark ? 0.11 : 0.14);
result.gutter = mixHex(bg, fg, dark ? 0.20 : 0.35);
result.fg = fg;
result.fgDim = mixHex(fg, bg, 0.32);
result.fgMuted = mixHex(fg, bg, 0.47);
if (accent)
result.accentAlt = mixHex(accent, dark ? "#ffffff" : "#000000", 0.18);
return normalizePalette(result);
}
// Scale the saturation of every token together. factor 1.0 is neutral;
// backgrounds move at quarter strength so the ground stays a ground.
function resaturatePalette(palette, factor) {
var origin = normalizePalette(palette);
if (!origin)
return null;
var scale = clamp(factor, 0, 2);
var result = {};
PALETTE_KEYS.forEach(function(key) {
var hsv = hexToHsv(origin[key]);
var strength = key.indexOf("bg") === 0 || key === "gutter" ? 0.25 : 1;
var next = clamp(hsv.s * (1 + (scale - 1) * strength), 0, 100);
result[key] = hsvToHex(hsv.h, next, hsv.v);
});
return result;
}
// Terminal colors for a custom theme that has none of its own: semantic
// tokens map straight onto ANSI, brights lift value a step.
function deriveAnsi(palette, accent) {
var origin = normalizePalette(palette);
if (!origin)
return null;
var blue = normalizedColor(accent) || origin.accentAlt;
function brighten(color) {
var hsv = hexToHsv(color);
return hsvToHex(hsv.h, Math.max(0, hsv.s - 8), Math.min(100, hsv.v + 12));
}
return normalizeAnsi({
black: origin.bgHighlight, red: origin.red, green: origin.green,
yellow: origin.yellow, blue: blue, magenta: origin.magenta,
cyan: origin.cyan, white: origin.fgDim,
brightBlack: origin.gutter, brightRed: brighten(origin.red),
brightGreen: brighten(origin.green), brightYellow: brighten(origin.yellow),
brightBlue: brighten(blue), brightMagenta: brighten(origin.magenta),
brightCyan: brighten(origin.cyan), brightWhite: origin.fg
});
}
function curatedNameForProfile(profile) {
if (!profile || !isScheme(profile.scheme))
return "";
@@ -402,6 +645,9 @@ function hexToHsv(value) {
if (typeof module !== "undefined") {
module.exports = {
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
PALETTE_KEYS: PALETTE_KEYS,
ANSI_KEYS: ANSI_KEYS,
EFFECT_SPEC: EFFECT_SPEC,
shippedProfiles: shippedProfiles,
curatedAccents: curatedAccents,
validCustomProfiles: validCustomProfiles,
@@ -413,6 +659,14 @@ if (typeof module !== "undefined") {
curatedPair: curatedPair,
matchingShippedProfile: matchingShippedProfile,
curatedNameForProfile: curatedNameForProfile,
nearestCuratedName: nearestCuratedName,
normalizePalette: normalizePalette,
normalizeAnsi: normalizeAnsi,
normalizeEffects: normalizeEffects,
mixHex: mixHex,
derivePalette: derivePalette,
resaturatePalette: resaturatePalette,
deriveAnsi: deriveAnsi,
hsvToHex: hsvToHex,
hexToHsv: hexToHsv
};