Files
mail/ui/components/theme-editor.slint
T

789 lines
38 KiB
Plaintext

import { HorizontalBox, VerticalBox } from "std-widgets.slint";
import { AppTheme, ThemeUtilities } from "../design-system.slint";
import { TypeScale } from "../typography.slint";
import { Button } from "button.slint";
import { LineEdit, RadioButton } from "controls.slint";
import { FocusVisibleScope } from "focus-visible-scope.slint";
component ThemePresetCard inherits FocusVisibleScope {
in property <string> name;
in property <color> preview-primary;
in property <color> preview-page;
in property <color> preview-surface;
in property <color> preview-text;
in property <bool> selected: false;
in property <length> label-bottom-padding: 10px;
callback chosen;
height: 44px + preset-name.height + root.label-bottom-padding;
min-width: 112px;
focus-on-click: true;
accessible-role: radio-button;
accessible-label: root.name;
accessible-checked: root.selected;
accessible-action-default => { root.chosen(); }
key-pressed(event) => {
if event.text == Key.Return || event.text == Key.Space {
root.chosen();
return accept;
}
reject
}
Rectangle {
border-radius: 8px;
border-width: root.selected || root.focus-visible ? 2px : 1px;
border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border;
background: preset-hit.has-hover ? AppTheme.control-bg : AppTheme.surface;
Rectangle {
x: 9px;
y: 9px;
width: parent.width - 18px;
height: 30px;
border-radius: 5px;
background: root.preview-page;
border-width: 1px;
border-color: root.preview-text.with-alpha(0.16);
Rectangle {
x: 5px;
y: 5px;
width: parent.width - 25px;
height: 20px;
border-radius: 4px;
background: root.preview-surface;
}
Rectangle {
x: parent.width - 15px;
y: 9px;
width: 9px;
height: 9px;
border-radius: 5px;
background: root.preview-primary;
}
}
preset-name := Text {
x: 10px;
y: 44px;
width: parent.width - 20px;
height: max(18px, self.preferred-height);
text: root.name;
color: root.selected ? AppTheme.accent : AppTheme.body;
font-size: TypeScale.caption;
font-weight: root.selected ? 700 : 600;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
preset-hit := TouchArea {
clicked => { root.pointer-activated(); root.chosen(); }
mouse-cursor: pointer;
}
}
}
component ThemeModeCard inherits FocusVisibleScope {
in property <string> name;
in property <string> mode;
in property <bool> selected: false;
callback chosen;
height: 51px + mode-name.height;
min-width: 92px;
focus-on-click: true;
accessible-role: radio-button;
accessible-label: root.name;
accessible-checked: root.selected;
accessible-action-default => { root.chosen(); }
key-pressed(event) => {
if event.text == Key.Return || event.text == Key.Space { root.chosen(); return accept; }
reject
}
Rectangle {
border-radius: 8px;
border-width: root.selected || root.focus-visible ? 2px : 1px;
border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border;
background: mode-hit.has-hover ? AppTheme.control-bg : AppTheme.surface;
Rectangle {
x: 9px; y: 8px; width: parent.width - 18px; height: 32px;
border-radius: 5px; border-width: 1px; border-color: AppTheme.border; clip: true;
background: root.mode == "dark" ? #18191a : #f2f2f0;
if root.mode == "system" : Rectangle { x: parent.width / 2; width: parent.width / 2; height: parent.height; background: #18191a; }
Rectangle {
x: 6px; y: 6px; width: parent.width - 12px; height: 20px;
border-radius: 3px;
background: root.mode == "dark" ? #242526 : root.mode == "light" ? #ffffff : @linear-gradient(90deg, #ffffff 0%, #ffffff 50%, #242526 50%, #242526 100%);
}
Rectangle { x: 10px; y: 11px; width: 28%; height: 3px; border-radius: 2px; background: #3b82f6; }
}
mode-name := Text {
x: 8px; y: 44px; width: parent.width - 16px; height: max(17px, self.preferred-height);
text: root.name; color: root.selected ? AppTheme.accent : AppTheme.body;
font-size: TypeScale.caption; font-weight: root.selected ? 700 : 600;
horizontal-alignment: center; vertical-alignment: center; overflow: elide;
}
mode-hit := TouchArea { clicked => { root.pointer-activated(); root.chosen(); } mouse-cursor: pointer; }
}
}
component ThemeColorChip inherits FocusVisibleScope {
in property <string> name;
in property <color> value;
in property <bool> selected: false;
callback chosen;
height: 48px;
min-width: 104px;
focus-on-click: true;
accessible-role: radio-button;
accessible-label: root.name + " " + ThemeUtilities.color-to-hex(root.value);
accessible-checked: root.selected;
accessible-action-default => { root.chosen(); }
key-pressed(event) => {
if event.text == Key.Return || event.text == Key.Space {
root.chosen();
return accept;
}
reject
}
Rectangle {
border-radius: 7px;
border-width: root.selected || root.focus-visible ? 2px : 1px;
border-color: root.selected || root.focus-visible ? AppTheme.accent : AppTheme.border-subtle;
background: chip-hit.has-hover ? AppTheme.control-bg : AppTheme.surface-raised;
HorizontalBox {
padding-left: 8px;
padding-right: 8px;
spacing: 7px;
Rectangle {
width: 24px;
height: 24px;
border-radius: 6px;
border-width: 1px;
border-color: AppTheme.border;
background: root.value;
}
Text {
horizontal-stretch: 1;
text: root.name;
color: AppTheme.body;
font-size: TypeScale.caption;
font-weight: root.selected ? 700 : 500;
vertical-alignment: center;
overflow: elide;
}
}
chip-hit := TouchArea {
clicked => { root.pointer-activated(); root.chosen(); }
mouse-cursor: pointer;
}
}
}
export component RuntimeColorPicker inherits Rectangle {
in-out property <color> value;
callback edited(color);
private property <float> hue: root.value.to-hsv().hue;
private property <float> saturation: root.value.to-hsv().saturation;
private property <float> brightness: root.value.to-hsv().value;
private property <string> hex-value: ThemeUtilities.color-to-hex(root.value);
private property <bool> hex-valid: ThemeUtilities.valid-color(root.hex-value);
height: 222px;
border-radius: 9px;
border-width: 1px;
border-color: AppTheme.border-subtle;
background: AppTheme.surface-raised;
init => {
// External bindings are resolved before the component is shown, but
// an in-out string keeps its type default until explicitly synced.
root.hex-value = ThemeUtilities.color-to-hex(root.value);
}
changed value => {
root.hex-value = ThemeUtilities.color-to-hex(root.value);
}
function update-saturation-value(x: length, y: length) {
let saturation = (x / sv-map.width).clamp(0, 1);
let brightness = (1 - y / sv-map.height).clamp(0, 1);
root.value = hsv(root.hue, saturation, brightness);
root.edited(root.value);
}
function update-hue(x: length) {
let hue = (x / hue-strip.width * 360).clamp(0, 360);
root.value = hsv(hue, root.saturation, root.brightness);
root.edited(root.value);
}
VerticalBox {
padding: 12px;
spacing: 10px;
sv-map := FocusVisibleScope {
height: 136px;
focus-on-click: true;
accessible-role: slider;
accessible-label: @tr("Color saturation and brightness");
accessible-value: @tr("Saturation {}%, brightness {}%", (root.saturation * 100).round(), (root.brightness * 100).round());
key-pressed(event) => {
if event.text == Key.LeftArrow {
root.value = hsv(root.hue, (root.saturation - 0.02).clamp(0, 1), root.brightness);
} else if event.text == Key.RightArrow {
root.value = hsv(root.hue, (root.saturation + 0.02).clamp(0, 1), root.brightness);
} else if event.text == Key.UpArrow {
root.value = hsv(root.hue, root.saturation, (root.brightness + 0.02).clamp(0, 1));
} else if event.text == Key.DownArrow {
root.value = hsv(root.hue, root.saturation, (root.brightness - 0.02).clamp(0, 1));
} else {
return reject;
}
root.edited(root.value);
return accept;
}
Rectangle {
border-radius: 6px;
background: hsv(root.hue, 1, 1);
clip: true;
Rectangle { background: @linear-gradient(90deg, white 0%, #ffffff00 100%); }
Rectangle { background: @linear-gradient(180deg, #00000000 0%, black 100%); }
sv-hit := TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down || (event.kind == PointerEventKind.move && self.pressed) {
sv-map.focus();
sv-map.pointer-activated();
root.update-saturation-value(self.mouse-x, self.mouse-y);
}
}
mouse-cursor: crosshair;
}
Rectangle {
x: (root.saturation * parent.width - self.width / 2).clamp(-self.width / 2, parent.width - self.width / 2);
y: ((1 - root.brightness) * parent.height - self.height / 2).clamp(-self.height / 2, parent.height - self.height / 2);
width: 14px;
height: 14px;
border-radius: 7px;
border-width: 2px;
border-color: white;
background: transparent;
drop-shadow-blur: 2px;
drop-shadow-color: #00000099;
}
Rectangle {
border-radius: 6px;
border-width: sv-map.focus-visible ? 2px : 1px;
border-color: sv-map.focus-visible ? AppTheme.accent : #ffffff44;
background: transparent;
}
}
}
hue-strip := FocusVisibleScope {
height: 20px;
focus-on-click: true;
accessible-role: slider;
accessible-label: @tr("Hue");
accessible-value: @tr("{} degrees", root.hue.round());
key-pressed(event) => {
if event.text == Key.LeftArrow {
root.value = hsv(root.hue - 3, root.saturation, root.brightness);
} else if event.text == Key.RightArrow {
root.value = hsv(root.hue + 3, root.saturation, root.brightness);
} else {
return reject;
}
root.edited(root.value);
return accept;
}
Rectangle {
border-radius: 6px;
background: @linear-gradient(90deg,
#ff0000 0%, #ffff00 16.67%, #00ff00 33.33%,
#00ffff 50%, #0000ff 66.67%, #ff00ff 83.33%, #ff0000 100%);
hue-hit := TouchArea {
pointer-event(event) => {
if event.kind == PointerEventKind.down || (event.kind == PointerEventKind.move && self.pressed) {
hue-strip.focus();
hue-strip.pointer-activated();
root.update-hue(self.mouse-x);
}
}
mouse-cursor: pointer;
}
Rectangle {
x: (root.hue / 360 * parent.width - self.width / 2).clamp(-self.width / 2, parent.width - self.width / 2);
y: -2px;
width: 8px;
height: parent.height + 4px;
border-radius: 4px;
border-width: 2px;
border-color: white;
background: transparent;
drop-shadow-blur: 2px;
drop-shadow-color: #00000099;
}
Rectangle {
border-radius: 6px;
border-width: hue-strip.focus-visible ? 2px : 1px;
border-color: hue-strip.focus-visible ? AppTheme.accent : #ffffff44;
background: transparent;
}
}
}
HorizontalBox {
height: 32px;
spacing: 9px;
Rectangle {
width: 32px;
height: 32px;
border-radius: 7px;
border-width: 1px;
border-color: AppTheme.border;
background: root.value;
}
// As tall as the swatch and field beside it, so all three share a center.
Text {
width: max(30px, self.preferred-width);
height: max(32px, self.preferred-height);
text: "HEX";
color: AppTheme.secondary;
font-size: TypeScale.caption;
font-weight: 700;
vertical-alignment: center;
}
hex-field := LineEdit {
horizontal-stretch: 1;
height: 32px;
text <=> root.hex-value;
invalid: !root.hex-valid;
placeholder-text: "#RRGGBB";
edited(value) => {
if ThemeUtilities.valid-color(value) {
root.value = ThemeUtilities.parse-color(value, root.value);
root.edited(root.value);
}
}
accepted(value) => {
if ThemeUtilities.valid-color(value) {
root.value = ThemeUtilities.parse-color(value, root.value);
root.edited(root.value);
}
}
}
}
}
}
struct ThemeContrastCheck {
label: string,
ratio: float,
}
// The custom palette as last saved while the editor previews unsaved
// changes live. It outlives the editor, which closing Settings or switching
// to another tab destroys, so the window can still discard the draft.
export global ThemeDraft {
in-out property <bool> dirty: false;
in-out property <color> light-primary;
in-out property <color> light-page-bg;
in-out property <color> light-surface;
in-out property <color> light-text;
in-out property <color> light-border;
in-out property <color> dark-primary;
in-out property <color> dark-page-bg;
in-out property <color> dark-surface;
in-out property <color> dark-text;
in-out property <color> dark-border;
// Remember the saved palette before the first unsaved change.
public function begin() {
if !root.dirty {
root.light-primary = AppTheme.custom-light-primary;
root.light-page-bg = AppTheme.custom-light-page-bg;
root.light-surface = AppTheme.custom-light-surface;
root.light-text = AppTheme.custom-light-text;
root.light-border = AppTheme.custom-light-border;
root.dark-primary = AppTheme.custom-dark-primary;
root.dark-page-bg = AppTheme.custom-dark-page-bg;
root.dark-surface = AppTheme.custom-dark-surface;
root.dark-text = AppTheme.custom-dark-text;
root.dark-border = AppTheme.custom-dark-border;
root.dirty = true;
}
}
// Put the saved palette back.
public function revert() {
if root.dirty {
AppTheme.custom-light-primary = root.light-primary;
AppTheme.custom-light-page-bg = root.light-page-bg;
AppTheme.custom-light-surface = root.light-surface;
AppTheme.custom-light-text = root.light-text;
AppTheme.custom-light-border = root.light-border;
AppTheme.custom-dark-primary = root.dark-primary;
AppTheme.custom-dark-page-bg = root.dark-page-bg;
AppTheme.custom-dark-surface = root.dark-surface;
AppTheme.custom-dark-text = root.dark-text;
AppTheme.custom-dark-border = root.dark-border;
root.dirty = false;
}
}
}
export component ThemeEditor inherits Rectangle {
in property <bool> compact: false;
in-out property <string> theme-mode: "system";
callback save-theme-mode(string);
callback save-preset(string);
callback save-custom-theme(color, color, color, color, color, color, color, color, color, color) -> bool;
callback discard-changes;
private property <string> selected-token: "primary";
private property <bool> editing-dark: AppTheme.dark;
private property <color> current-primary: root.editing-dark ? AppTheme.custom-dark-primary : AppTheme.custom-light-primary;
private property <color> current-page-bg: root.editing-dark ? AppTheme.custom-dark-page-bg : AppTheme.custom-light-page-bg;
private property <color> current-surface: root.editing-dark ? AppTheme.custom-dark-surface : AppTheme.custom-light-surface;
private property <color> current-text: root.editing-dark ? AppTheme.custom-dark-text : AppTheme.custom-light-text;
private property <color> current-border: root.editing-dark ? AppTheme.custom-dark-border : AppTheme.custom-light-border;
private property <color> current-value: root.selected-token == "primary" ? root.current-primary
: root.selected-token == "page" ? root.current-page-bg
: root.selected-token == "surface" ? root.current-surface
: root.selected-token == "text" ? root.current-text
: root.current-border;
private property <float> light-text-page-contrast: ThemeUtilities.contrast(AppTheme.custom-light-text, AppTheme.custom-light-page-bg);
private property <float> light-text-surface-contrast: ThemeUtilities.contrast(AppTheme.custom-light-text, AppTheme.custom-light-surface);
private property <float> light-action-contrast: ThemeUtilities.contrast(ThemeUtilities.contrasting-foreground(AppTheme.custom-light-primary), AppTheme.custom-light-primary);
private property <color> light-faint-text: AppTheme.custom-light-text.mix(AppTheme.custom-light-page-bg, 0.68);
private property <float> light-faint-page-contrast: ThemeUtilities.contrast(root.light-faint-text, AppTheme.custom-light-page-bg);
private property <float> light-faint-surface-contrast: ThemeUtilities.contrast(root.light-faint-text, AppTheme.custom-light-surface);
private property <float> dark-text-page-contrast: ThemeUtilities.contrast(AppTheme.custom-dark-text, AppTheme.custom-dark-page-bg);
private property <float> dark-text-surface-contrast: ThemeUtilities.contrast(AppTheme.custom-dark-text, AppTheme.custom-dark-surface);
private property <float> dark-action-contrast: ThemeUtilities.contrast(ThemeUtilities.contrasting-foreground(AppTheme.custom-dark-primary), AppTheme.custom-dark-primary);
private property <color> dark-faint-text: AppTheme.custom-dark-text.mix(AppTheme.custom-dark-page-bg, 0.68);
private property <float> dark-faint-page-contrast: ThemeUtilities.contrast(root.dark-faint-text, AppTheme.custom-dark-page-bg);
private property <float> dark-faint-surface-contrast: ThemeUtilities.contrast(root.dark-faint-text, AppTheme.custom-dark-surface);
private property <float> minimum-contrast: min(
min(
min(min(root.light-text-page-contrast, root.light-text-surface-contrast), root.light-action-contrast),
min(root.light-faint-page-contrast, root.light-faint-surface-contrast),
),
min(
min(min(root.dark-text-page-contrast, root.dark-text-surface-contrast), root.dark-action-contrast),
min(root.dark-faint-page-contrast, root.dark-faint-surface-contrast),
),
);
private property <bool> contrast-valid: root.minimum-contrast >= 4.5;
private property <[ThemeContrastCheck]> contrast-checks: [
{ label: @tr("Light · Text on canvas"), ratio: root.light-text-page-contrast },
{ label: @tr("Light · Text on surface"), ratio: root.light-text-surface-contrast },
{ label: @tr("Light · Secondary text on canvas"), ratio: root.light-faint-page-contrast },
{ label: @tr("Light · Secondary text on surface"), ratio: root.light-faint-surface-contrast },
{ label: @tr("Light · Primary button text"), ratio: root.light-action-contrast },
{ label: @tr("Dark · Text on canvas"), ratio: root.dark-text-page-contrast },
{ label: @tr("Dark · Text on surface"), ratio: root.dark-text-surface-contrast },
{ label: @tr("Dark · Secondary text on canvas"), ratio: root.dark-faint-page-contrast },
{ label: @tr("Dark · Secondary text on surface"), ratio: root.dark-faint-surface-contrast },
{ label: @tr("Dark · Primary button text"), ratio: root.dark-action-contrast },
];
vertical-stretch: 0;
height: max(
AppTheme.preset == "custom"
? (root.compact ? 970px : 690px)
: (root.compact ? 420px : 272px),
editor-content.preferred-height);
border-radius: 8px;
border-width: 1px;
border-color: AppTheme.border-subtle;
background: AppTheme.surface;
function begin-edit() {
ThemeDraft.begin();
}
function revert() {
ThemeDraft.revert();
}
function choose-mode(mode: string) {
root.theme-mode = mode;
if mode == "dark" { root.editing-dark = true; }
else if mode == "light" { root.editing-dark = false; }
root.save-theme-mode(mode);
}
function choose-preset(preset: string) {
AppTheme.preset = preset;
if preset == "custom" { root.editing-dark = AppTheme.dark; }
root.save-preset(preset);
}
function set-current-value(value: color) {
root.begin-edit();
if root.selected-token == "primary" {
if root.editing-dark { AppTheme.custom-dark-primary = value; }
else { AppTheme.custom-light-primary = value; }
} else if root.selected-token == "page" {
if root.editing-dark { AppTheme.custom-dark-page-bg = value; }
else { AppTheme.custom-light-page-bg = value; }
} else if root.selected-token == "surface" {
if root.editing-dark { AppTheme.custom-dark-surface = value; }
else { AppTheme.custom-light-surface = value; }
} else if root.selected-token == "text" {
if root.editing-dark { AppTheme.custom-dark-text = value; }
else { AppTheme.custom-light-text = value; }
} else {
if root.editing-dark { AppTheme.custom-dark-border = value; }
else { AppTheme.custom-light-border = value; }
}
}
function reset-current-palette() {
root.begin-edit();
if root.editing-dark {
AppTheme.custom-dark-primary = #0969da;
AppTheme.custom-dark-page-bg = #111213;
AppTheme.custom-dark-surface = #18191a;
AppTheme.custom-dark-text = #f3f3f2;
AppTheme.custom-dark-border = #3a3b3c;
} else {
AppTheme.custom-light-primary = #0969da;
AppTheme.custom-light-page-bg = #f2f2f0;
AppTheme.custom-light-surface = #ffffff;
AppTheme.custom-light-text = #202120;
AppTheme.custom-light-border = #d9d9d6;
}
}
function persist() {
if ThemeDraft.dirty {
let saved = root.save-custom-theme(
AppTheme.custom-light-primary,
AppTheme.custom-light-page-bg,
AppTheme.custom-light-surface,
AppTheme.custom-light-text,
AppTheme.custom-light-border,
AppTheme.custom-dark-primary,
AppTheme.custom-dark-page-bg,
AppTheme.custom-dark-surface,
AppTheme.custom-dark-text,
AppTheme.custom-dark-border,
);
if saved {
ThemeDraft.dirty = false;
}
}
}
discard-changes => { root.revert(); }
editor-content := VerticalBox {
padding-top: 16px;
padding-right: 18px;
padding-bottom: 16px;
padding-left: 18px;
spacing: 10px;
Text {
height: max(22px, self.preferred-height);
text: @tr("Theme");
color: AppTheme.heading;
font-size: TypeScale.subtitle;
font-weight: 700;
vertical-alignment: center;
}
Text { height: max(18px, self.preferred-height); text: @tr("Color scheme"); color: AppTheme.secondary; font-size: TypeScale.secondary; vertical-alignment: center; }
HorizontalBox {
padding: 0px;
spacing: 8px;
ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("System"); mode: "system"; selected: root.theme-mode == "system"; chosen => { root.choose-mode("system"); } }
ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("Dark"); mode: "dark"; selected: root.theme-mode == "dark"; chosen => { root.choose-mode("dark"); } }
ThemeModeCard { horizontal-stretch: 1; min-width: 0px; name: @tr("Light"); mode: "light"; selected: root.theme-mode == "light"; chosen => { root.choose-mode("light"); } }
}
Rectangle { height: 1px; background: AppTheme.border-subtle; }
Text { height: max(18px, self.preferred-height); text: @tr("Palette"); color: AppTheme.secondary; font-size: TypeScale.caption; font-weight: 700; vertical-alignment: center; }
if !root.compact : HorizontalBox {
padding: 0px;
spacing: 8px;
ThemePresetCard { horizontal-stretch: 1; name: @tr("Default"); preview-primary: #0969da; preview-page: AppTheme.dark ? #111213 : #f2f2f0; preview-surface: AppTheme.dark ? #18191a : white; preview-text: AppTheme.dark ? #f3f3f2 : #202120; selected: AppTheme.preset == "default"; chosen => { root.choose-preset("default"); } }
ThemePresetCard { horizontal-stretch: 1; name: @tr("Teal"); preview-primary: #006b7a; preview-page: AppTheme.dark ? #0b1519 : #edf5f7; preview-surface: AppTheme.dark ? #122026 : #fbfefe; preview-text: AppTheme.dark ? #e5f0f2 : #173039; selected: AppTheme.preset == "teal"; chosen => { root.choose-preset("teal"); } }
ThemePresetCard { horizontal-stretch: 1; name: @tr("Green"); preview-primary: #2f6f44; preview-page: AppTheme.dark ? #101711 : #f1f5ef; preview-surface: AppTheme.dark ? #182019 : #fcfdfb; preview-text: AppTheme.dark ? #e9f0e6 : #213026; selected: AppTheme.preset == "green"; chosen => { root.choose-preset("green"); } }
ThemePresetCard { horizontal-stretch: 1; name: @tr("Purple"); preview-primary: #78418a; preview-page: AppTheme.dark ? #171119 : #f6f1f7; preview-surface: AppTheme.dark ? #201722 : #fdfbfe; preview-text: AppTheme.dark ? #f1e8f3 : #332537; selected: AppTheme.preset == "purple"; chosen => { root.choose-preset("purple"); } }
ThemePresetCard { horizontal-stretch: 1; name: @tr("Custom"); preview-primary: root.current-primary; preview-page: root.current-page-bg; preview-surface: root.current-surface; preview-text: root.current-text; selected: AppTheme.preset == "custom"; chosen => { root.choose-preset("custom"); } }
}
if root.compact : VerticalBox {
padding: 0px;
spacing: 8px;
HorizontalBox {
padding: 0px;
spacing: 8px;
ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Default"); preview-primary: #0969da; preview-page: AppTheme.dark ? #111213 : #f2f2f0; preview-surface: AppTheme.dark ? #18191a : white; preview-text: AppTheme.dark ? #f3f3f2 : #202120; selected: AppTheme.preset == "default"; chosen => { root.choose-preset("default"); } }
ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Teal"); preview-primary: #006b7a; preview-page: AppTheme.dark ? #0b1519 : #edf5f7; preview-surface: AppTheme.dark ? #122026 : #fbfefe; preview-text: AppTheme.dark ? #e5f0f2 : #173039; selected: AppTheme.preset == "teal"; chosen => { root.choose-preset("teal"); } }
}
HorizontalBox {
padding: 0px;
spacing: 8px;
ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Green"); preview-primary: #2f6f44; preview-page: AppTheme.dark ? #101711 : #f1f5ef; preview-surface: AppTheme.dark ? #182019 : #fcfdfb; preview-text: AppTheme.dark ? #e9f0e6 : #213026; selected: AppTheme.preset == "green"; chosen => { root.choose-preset("green"); } }
ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Purple"); preview-primary: #78418a; preview-page: AppTheme.dark ? #171119 : #f6f1f7; preview-surface: AppTheme.dark ? #201722 : #fdfbfe; preview-text: AppTheme.dark ? #f1e8f3 : #332537; selected: AppTheme.preset == "purple"; chosen => { root.choose-preset("purple"); } }
}
HorizontalBox {
padding: 0px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
ThemePresetCard { horizontal-stretch: 1; min-width: 0px; label-bottom-padding: 6px; name: @tr("Custom"); preview-primary: root.current-primary; preview-page: root.current-page-bg; preview-surface: root.current-surface; preview-text: root.current-text; selected: AppTheme.preset == "custom"; chosen => { root.choose-preset("custom"); } }
Rectangle { horizontal-stretch: 1; }
}
}
if AppTheme.preset == "custom" : Rectangle { height: 1px; background: AppTheme.border-subtle; }
// A phone row only fits the mode choice, so the heading and the save
// state get their own lines there.
if AppTheme.preset == "custom" && root.compact : Text {
text: @tr("Custom palette");
color: AppTheme.heading;
font-size: TypeScale.body;
font-weight: 700;
wrap: word-wrap;
}
// The radio buttons set the row height; vertical padding would leave
// the texts beside them too short to show.
if AppTheme.preset == "custom" : HorizontalBox {
height: max(26px, self.preferred-height);
padding-top: 0px;
padding-bottom: 0px;
if !root.compact : Text {
text: @tr("Custom palette");
color: AppTheme.heading;
font-size: TypeScale.body;
font-weight: 700;
vertical-alignment: center;
}
RadioButton {
height: 26px;
text: @tr("Light");
checked: !root.editing-dark;
clicked => { root.editing-dark = false; }
}
RadioButton {
height: 26px;
text: @tr("Dark");
checked: root.editing-dark;
clicked => { root.editing-dark = true; }
}
Rectangle { horizontal-stretch: 1; }
if !root.compact : Text {
text: ThemeDraft.dirty ? @tr("Unsaved changes") : @tr("Saved");
color: ThemeDraft.dirty ? AppTheme.accent : AppTheme.secondary;
font-size: TypeScale.caption;
vertical-alignment: center;
}
}
if AppTheme.preset == "custom" && root.compact : Text {
text: ThemeDraft.dirty ? @tr("Unsaved changes") : @tr("Saved");
color: ThemeDraft.dirty ? AppTheme.accent : AppTheme.secondary;
font-size: TypeScale.caption;
wrap: word-wrap;
}
if AppTheme.preset == "custom" && !root.compact : HorizontalBox {
height: 48px;
spacing: 8px;
ThemeColorChip { horizontal-stretch: 1; name: @tr("Primary"); value: root.current-primary; selected: root.selected-token == "primary"; chosen => { root.selected-token = "primary"; } }
ThemeColorChip { horizontal-stretch: 1; name: @tr("Canvas"); value: root.current-page-bg; selected: root.selected-token == "page"; chosen => { root.selected-token = "page"; } }
ThemeColorChip { horizontal-stretch: 1; name: @tr("Surface"); value: root.current-surface; selected: root.selected-token == "surface"; chosen => { root.selected-token = "surface"; } }
ThemeColorChip { horizontal-stretch: 1; name: @tr("Text"); value: root.current-text; selected: root.selected-token == "text"; chosen => { root.selected-token = "text"; } }
ThemeColorChip { horizontal-stretch: 1; name: @tr("Border"); value: root.current-border; selected: root.selected-token == "border"; chosen => { root.selected-token = "border"; } }
}
if AppTheme.preset == "custom" && root.compact : VerticalBox {
height: 160px;
padding: 0px;
spacing: 8px;
HorizontalBox {
height: 48px;
padding: 0px;
spacing: 8px;
ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Primary"); value: root.current-primary; selected: root.selected-token == "primary"; chosen => { root.selected-token = "primary"; } }
ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Canvas"); value: root.current-page-bg; selected: root.selected-token == "page"; chosen => { root.selected-token = "page"; } }
}
HorizontalBox {
height: 48px;
padding: 0px;
spacing: 8px;
ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Surface"); value: root.current-surface; selected: root.selected-token == "surface"; chosen => { root.selected-token = "surface"; } }
ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Text"); value: root.current-text; selected: root.selected-token == "text"; chosen => { root.selected-token = "text"; } }
}
HorizontalBox {
height: 48px;
padding: 0px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
ThemeColorChip { horizontal-stretch: 1; min-width: 0px; name: @tr("Border"); value: root.current-border; selected: root.selected-token == "border"; chosen => { root.selected-token = "border"; } }
Rectangle { horizontal-stretch: 1; }
}
}
if AppTheme.preset == "custom" : RuntimeColorPicker {
height: 222px;
value: root.current-value;
edited(value) => { root.set-current-value(value); }
}
if AppTheme.preset == "custom" : contrast-status := Rectangle {
height: max(root.compact ? 54px : 34px, warning-layout.preferred-height);
border-radius: 6px;
private property <color> fill-color: root.contrast-valid ? AppTheme.accent-soft : AppTheme.destructive.mix(AppTheme.surface, 0.12);
private property <color> foreground: ThemeUtilities.contrasting-foreground(self.fill-color);
background: self.fill-color;
warning-layout := VerticalLayout {
padding: 10px;
spacing: 0px;
Text {
text: root.contrast-valid
? @tr("Checked text contrast meets 4.5:1 · minimum {}:1", root.minimum-contrast.to-fixed(2))
: @tr("Low contrast · 4.5:1 is recommended for text. You can still save this palette.");
color: contrast-status.foreground;
font-size: TypeScale.caption;
font-weight: 600;
wrap: word-wrap;
}
for check in root.contrast-checks : Rectangle {
height: check.ratio < 4.5 ? check-label.preferred-height + 4px : 0px;
visible: check.ratio < 4.5;
check-label := Text {
y: 4px;
width: parent.width;
height: self.preferred-height;
text: @tr("{} · {}:1", check.label, check.ratio.to-fixed(2));
color: contrast-status.foreground;
font-size: TypeScale.caption;
wrap: word-wrap;
}
}
}
}
// A phone row may be too narrow for three long translated labels.
if AppTheme.preset == "custom" : HorizontalBox {
height: 32px;
spacing: 8px;
Button { text: root.compact ? @tr("Reset") : @tr("Reset this palette"); label-overflow: elide; clicked => { root.reset-current-palette(); } }
Rectangle { horizontal-stretch: 1; }
Button { text: @tr("Revert"); label-overflow: elide; enabled: ThemeDraft.dirty; clicked => { root.revert(); } }
Button { text: root.compact ? @tr("Save") : @tr("Save palette"); label-overflow: elide; primary: true; enabled: ThemeDraft.dirty; clicked => { root.persist(); } }
}
}
}