Files
Panama/config/dot/quickshell/services/ThemeProfileModel.js
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

674 lines
22 KiB
JavaScript

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",
name: "Moon",
scheme: "dark",
accent: "#82aaff",
secondary: "#b172b0",
shipped: true
},
{
id: "moon-rose",
name: "Moon Rose",
scheme: "dark",
accent: "#ff757f",
secondary: "#c099ff",
shipped: true
},
{
id: "day",
name: "Day",
scheme: "light",
accent: "#2e7de9",
secondary: "#9854f1",
shipped: true
}
];
// The curated accents. `gnome` is the nearest member of GNOME's own
// accent-color enum, which is a fixed list of nine we do not get to extend. It
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
// choosing an accent here recolors them too instead of leaving them in GNOME
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
// because it is the red role in this palette. adwaita-accent-contract reads
// this table and fails when a member is missing or is not in GNOME's enum.
var CURATED = {
blue: {
dark: "#82aaff", darkSecondary: "#b172b0",
light: "#2e7de9", lightSecondary: "#9854f1",
label: "Prism blue",
gnome: "blue"
},
orchid: {
dark: "#c099ff", darkSecondary: "#fca7ea",
light: "#7847bd", lightSecondary: "#9854f1",
label: "Orchid",
gnome: "purple"
},
teal: {
dark: "#86e1fc", darkSecondary: "#82aaff",
light: "#007197", lightSecondary: "#2e7de9",
label: "Teal",
gnome: "teal"
},
green: {
dark: "#c3e88d", darkSecondary: "#86e1fc",
light: "#587539", lightSecondary: "#007197",
label: "Green",
gnome: "green"
},
amber: {
dark: "#ffc777", darkSecondary: "#ff966c",
light: "#8c6c3e", lightSecondary: "#b15c00",
label: "Amber",
gnome: "yellow"
},
orange: {
dark: "#ff966c", darkSecondary: "#ff757f",
light: "#b15c00", lightSecondary: "#c64343",
label: "Orange",
gnome: "orange"
},
rose: {
dark: "#ff757f", darkSecondary: "#c099ff",
light: "#f52a65", lightSecondary: "#9854f1",
label: "Rose",
gnome: "red"
},
slate: {
dark: "#828bb8", darkSecondary: "#82aaff",
light: "#6172b0", lightSecondary: "#2e7de9",
label: "Slate",
gnome: "slate"
}
};
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) {
var result = {
id: profile.id,
name: profile.name,
scheme: profile.scheme,
accent: profile.accent,
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 shippedList(shipped) {
return Array.isArray(shipped) && shipped.length > 0 ? shipped : SHIPPED;
}
function shippedProfiles(shipped) {
return shippedList(shipped).map(copyProfile);
}
function curatedAccents() {
var result = {};
Object.keys(CURATED).forEach(function(name) {
result[name] = Object.assign({}, CURATED[name]);
});
return result;
}
function isScheme(value) {
return value === "dark" || value === "light";
}
function normalizedColor(value) {
var color = String(value || "").trim().toLowerCase();
return /^#[0-9a-f]{6}$/.test(color) ? color : null;
}
function normalizeStoredProfile(value) {
if (!value || typeof value !== "object" || value.shipped === true)
return null;
var id = String(value.id || "").trim();
var name = String(value.name || "").trim();
var accent = normalizedColor(value.accent);
var secondary = normalizedColor(value.secondary);
if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
|| name.length === 0 || name.length > MAX_NAME_LENGTH
|| !isScheme(value.scheme) || !accent || !secondary)
return null;
var result = {
id: id,
name: name,
scheme: value.scheme,
accent: accent,
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, shipped) {
if (!Array.isArray(values))
return [];
var ids = {};
var names = {};
shippedList(shipped).forEach(function(profile) {
ids[profile.id] = true;
names[profile.name.toLowerCase()] = true;
});
var result = [];
values.forEach(function(value) {
var profile = normalizeStoredProfile(value);
if (!profile)
return;
var foldedName = profile.name.toLowerCase();
if (ids[profile.id] || names[foldedName])
return;
ids[profile.id] = true;
names[foldedName] = true;
result.push(profile);
});
return result;
}
function profileCatalog(values, shipped) {
return shippedProfiles(shipped).concat(validCustomProfiles(values, shipped));
}
function boundedName(value) {
var name = String(value || "").trim();
if (!name)
name = "Custom theme";
return name.slice(0, MAX_NAME_LENGTH);
}
function uniqueName(value, profiles) {
var requested = boundedName(value);
var names = {};
profiles.forEach(function(profile) {
names[profile.name.toLowerCase()] = true;
});
if (!names[requested.toLowerCase()])
return requested;
for (var suffix = 2; suffix < 10000; suffix++) {
var ending = " " + suffix;
var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
if (!names[candidate.toLowerCase()])
return candidate;
}
return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
}
function slug(value) {
var result = String(value || "").toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/g, "");
return result || "theme";
}
function uniqueId(name, profiles) {
var base = "custom-" + slug(name);
var ids = {};
profiles.forEach(function(profile) { ids[profile.id] = true; });
if (!ids[base])
return base;
for (var suffix = 2; suffix < 10000; suffix++) {
var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
if (!ids[candidate])
return candidate;
}
return base.slice(0, 52) + "-10000";
}
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);
if (!isScheme(scheme) || !accent || !secondary)
return { profiles: customs, profile: null };
var name = uniqueName(input && input.name, catalog);
var profile = {
id: uniqueId(name, catalog),
name: name,
scheme: scheme,
accent: accent,
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, shipped) {
var catalog = profileCatalog(values, shipped);
for (var index = 0; index < catalog.length; index++) {
if (catalog[index].id === id)
return catalog[index];
}
return null;
}
function editProfile(values, selected, changes, shipped) {
var customs = validCustomProfiles(values, shipped);
if (!selected || typeof selected !== "object")
return { profiles: customs, profile: null };
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,
palette: pick("palette", selected.palette),
ansi: pick("ansi", selected.ansi),
effects: pick("effects", selected.effects)
}, shipped);
}
var stored = normalizeStoredProfile(selected);
if (!stored)
return { profiles: customs, profile: null };
var updated = Object.assign({}, stored, {
scheme: scheme,
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)
return profile;
found = true;
return updated;
});
if (!found)
next.push(updated);
return { profiles: next, profile: updated };
}
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; });
return { profiles: next, removed: next.length !== customs.length };
}
function curatedPair(name, scheme) {
var entry = CURATED[name] || CURATED.blue;
if (scheme === "light")
return { accent: entry.light, secondary: entry.lightSecondary };
return { accent: entry.dark, secondary: entry.darkSecondary };
}
function matchingShippedProfile(scheme, accent, secondary, shipped) {
var first = normalizedColor(accent);
var second = normalizedColor(secondary);
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 "";
var accent = normalizedColor(profile.accent);
var secondary = normalizedColor(profile.secondary);
var names = Object.keys(CURATED);
for (var index = 0; index < names.length; index++) {
var name = names[index];
var pair = curatedPair(name, profile.scheme);
if (pair.accent === accent && pair.secondary === secondary)
return name;
}
return "";
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, Number(value)));
}
function channelHex(value) {
var text = Math.round(value).toString(16);
return text.length < 2 ? "0" + text : text;
}
function hsvToHex(hue, saturation, value) {
var h = Number(hue);
var s = Number(saturation);
var v = Number(value);
if (!isFinite(h) || !isFinite(s) || !isFinite(v))
return null;
h = ((h % 360) + 360) % 360;
s = clamp(s, 0, 100) / 100;
v = clamp(v, 0, 100) / 100;
var chroma = v * s;
var section = h / 60;
var x = chroma * (1 - Math.abs(section % 2 - 1));
var red = 0;
var green = 0;
var blue = 0;
if (section < 1) { red = chroma; green = x; }
else if (section < 2) { red = x; green = chroma; }
else if (section < 3) { green = chroma; blue = x; }
else if (section < 4) { green = x; blue = chroma; }
else if (section < 5) { red = x; blue = chroma; }
else { red = chroma; blue = x; }
var match = v - chroma;
return "#" + channelHex((red + match) * 255)
+ channelHex((green + match) * 255)
+ channelHex((blue + match) * 255);
}
function hexToHsv(value) {
var color = normalizedColor(value);
if (!color)
return null;
var red = parseInt(color.slice(1, 3), 16) / 255;
var green = parseInt(color.slice(3, 5), 16) / 255;
var blue = parseInt(color.slice(5, 7), 16) / 255;
var maximum = Math.max(red, green, blue);
var minimum = Math.min(red, green, blue);
var delta = maximum - minimum;
var hue = 0;
if (delta !== 0) {
if (maximum === red)
hue = 60 * (((green - blue) / delta) % 6);
else if (maximum === green)
hue = 60 * ((blue - red) / delta + 2);
else
hue = 60 * ((red - green) / delta + 4);
}
if (hue < 0)
hue += 360;
return {
h: Math.round(hue),
s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
v: Math.round(maximum * 100)
};
}
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,
profileCatalog: profileCatalog,
createCustomProfile: createCustomProfile,
findProfile: findProfile,
editProfile: editProfile,
deleteProfile: deleteProfile,
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
};
}