Files
Nomarchy/modules/home/stylix.nix
Bernardo Magri c32b51897e
All checks were successful
Check / eval (push) Successful in 3m44s
fix(theme): #130 the chip moves, not the label; #129 pins the pair so it is checkable
Light-theme accent buttons sat at 2.72:1 — under AA and under even the 3:1
large-text floor. Bernardo: fix it. The plan on file ("darken the label")
turned out to be IMPOSSIBLE, and measuring all 24 palettes is what showed
it: on a saturated mid-tone accent nothing clears 4.5 — cream base 2.72,
dark text 1.65. That is why adw-gtk3 ships white-on-accent at ~2.7. The
chip has to move.

The chip is now the accent pulled AWAY from the label: toward white when
the label is dark, toward black when it is cream. One factor (0.70), only
the anchor flips, so "solid accent button + base label" stays the design
and just shifts tone. Worst dark 4.86 (nord/bad), worst light 5.59
(summer-day). The old 0.90 chip failed SEVEN themes (miasma/bad 3.43,
rose-pine 2.70) — never only a light-theme bug, contrary to the item.

#129 rides along, and the two are one idea. #98 shipped an invisible button
with every check green because the background was not a palette role —
adw-gtk3 computed it from currentColor. So the guard is not a CSS lint
(which would flag the harmless generic-button rule and get muted) but
arithmetic: pin both ends and the pair becomes checkable.
check-theme-contrast.py now models GTK's own mix(a,b,f) = a + (b-a)*f —
READ from gtkcsscolorvalue.c:234, not assumed — and asserts base-on-chip
>= 4.5 for all 24 themes. Its negative test reproduces the item's reported
2.72/2.74 exactly from an independent model, which is the best evidence
available that the arithmetic matches the pixels.

Invariant to keep: pin the pair, or pin neither. Both chips are pinned now
(suggested + .default + destructive), where #98 pinned only destructive.

tools/dialog-shot.nix deliberately not built: the checker covers the
numbers, and a VM that only ever confirms arithmetic is maintenance for
nothing. Render if a new widget class appears.

Also sweeps #127 with the latch finding (ff5017e): symptom 2 solved, not
DPMS — which raises the odds the mitigation treats the wrong cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:55:17 +01:00

287 lines
13 KiB
Nix

# Stylix — themes the long tail of applications (GTK, Qt, cursors,
# fonts) from the same state.json that drives the live engine.
#
# Division of labour: the hot-reload trio (Hyprland, Waybar, Kitty)
# is owned by the Nomarchy engine and updates instantly; everything
# Stylix touches updates on the next home-manager switch. That is why
# autoEnable is off and the trio's Stylix targets stay disabled.
#
# Note: the stylix home module itself is imported by homeModules.nomarchy
# in flake.nix (it needs the stylix flake input).
{ config, lib, pkgs, ... }:
let
cfg = config.nomarchy;
t = cfg.theme;
c = t.colors;
hex = lib.removePrefix "#";
# Accent-button chips (#130). The label is the palette's base — dark on a
# dark theme, cream on a light one — so the chip has to be pulled AWAY from
# the label until the label is legible: toward white when the label is dark,
# toward black when it is cream. Same factor both ways; only the anchor
# flips, so "solid accent button, base label" stays the design and just
# shifts tone.
#
# Why a nudge at all: on a saturated mid-tone accent NOTHING clears 4.5 —
# neither the cream base (summer-day: 2.72) nor the dark text (1.65) — which
# is why upstream adw-gtk3 ships white-on-accent at ~2.7 and why "darken the
# label" was a dead end. The chip must move.
#
# 0.70 measured across all 24 palettes (tools/check-theme-contrast.py asserts
# it): worst dark 4.86 (nord/bad), worst light 5.59 (summer-day). Today's 0.90
# fails seven themes — miasma/bad 3.43, rose-pine 2.70 — so this is not only a
# light-theme fix, which is what the item assumed.
#
# GTK's mix(a, b, f) = a + (b - a) * f (gtkcsscolorvalue.c), i.e. f is the
# weight of the SECOND colour: 0.70 = 70% accent, 30% anchor. Read it that way
# or the ladder below looks inverted.
chipAnchor = if t.mode == "light" then "black" else "white";
# Each state pulls further from the label, so contrast only ever improves —
# rest is the worst case and the one the check pins.
chip = role: f: "mix(${chipAnchor}, ${role}, ${f})";
# Map the Nomarchy palette onto base16 roles.
base16 = {
base00 = hex c.base; # default background
base01 = hex c.mantle; # darker background (status bars)
base02 = hex c.surface; # selection background
base03 = hex c.muted; # comments
base04 = hex c.subtext; # dark foreground
base05 = hex c.text; # default foreground
base06 = hex c.text; # light foreground
base07 = hex c.text; # lightest foreground
base08 = hex c.bad; # red
base09 = hex c.warn; # orange
base0A = hex c.warn; # yellow
base0B = hex c.good; # green
base0C = hex (builtins.elemAt t.ansi 6); # cyan
base0D = hex c.accent; # blue
base0E = hex c.accentAlt;# magenta
base0F = hex c.bad; # brown/deprecated
};
in
{
config = lib.mkIf cfg.stylix.enable {
stylix = {
enable = true;
autoEnable = false; # explicit targets only — the engine owns the rest
polarity = if t.mode == "light" then "light" else "dark";
base16Scheme = base16;
targets = {
gtk = {
enable = true;
# nm-applet / classic GTK menus: Stylix base16 selection can
# collapse into surface/muted on dark themes (Boreal: selected
# row and submenu arrows were effectively invisible). High-
# contrast selected rows + explicit arrow colour. Appended to
# Stylix's generated gtk.css (not gtk.gtk3.extraCss — that is a
# no-op under Stylix).
#
# #98: dialog button labels. Stylix always installs theme name
# "adw-gtk3" (light sheet) and only recolors @define-color vars.
# The light sheet hardcodes dark/black button text in places; on a
# dark palette that becomes black-on-dark in GTK dialogs. We force
# adw-gtk3-dark for dark mode (below) and still pin button label
# colours here so dialogs stay legible if a light sheet slips in.
extraCss = ''
menu menuitem, .menu menuitem, .context-menu menuitem {
color: #${base16.base05};
}
menu menuitem:hover, menu menuitem:selected,
.menu menuitem:hover, .menu menuitem:selected,
.context-menu menuitem:hover, .context-menu menuitem:selected,
menubar > menuitem:hover {
background-color: #${base16.base0D};
color: #${base16.base00};
}
menu menuitem:disabled, .menu menuitem:disabled {
color: #${base16.base03};
}
/* Submenu disclosure arrows need no icon remap: pan-end-symbolic
resolves via Papirus-Dark's breeze-dark inheritance. The
"invisible arrow" bug was Waybar's process-wide stylesheet:
its `* { min-height: 0; }` reset also hits the SNI tray menus
Waybar hosts and collapses arrow/check nodes. NB: this file
CANNOT fix that Waybar adds its provider at USER priority
too and wins the tie in practice (GTK 3.24, observed), so
every waybar.css carries its own menu counter-rules. The
rules here cover menus in ordinary GTK apps only. */
menu menuitem arrow, .menu menuitem arrow, .context-menu menuitem arrow {
min-width: 16px;
min-height: 16px;
}
check, radio {
min-width: 14px;
min-height: 14px;
color: inherit;
}
/* GTK4 / popover menus (some portals). */
popover.menu contents modelbutton {
color: #${base16.base05};
}
popover.menu contents modelbutton:hover,
popover.menu contents modelbutton:selected {
background-color: #${base16.base0D};
color: #${base16.base00};
}
/* Dialog / message-box action buttons (#98). */
button, button label,
.dialog-action-area button,
.dialog-action-area button label,
messagedialog button,
messagedialog button label,
.message-dialog button,
.message-dialog button label {
color: #${base16.base05};
}
button:disabled, button:disabled label {
color: alpha(#${base16.base05}, 0.5);
}
/* Accent buttons: the palette's base as the label, on a chip
pulled away from it (#130 see `chip` above for the why and
the measured numbers).
BOTH chips are pinned, not just destructive as in #98. Two
reasons, and the second is the durable one:
1. adw-gtk3 builds the destructive background from
currentColor mix(@destructive_color,
alpha(currentColor,)) so pinning the label alone drags
the background with it and the button renders dark-on-dark
(1.03:1, invisible). That is the #98 bug.
2. A pinned label on an UNPINNED background is unknowable:
no static check can see what it renders on, which is
exactly why every check was green while #98 shipped. A
pinned pair is arithmetic, and
tools/check-theme-contrast.py now asserts it for all 24
palettes. The rule for anything added here: pin the pair,
or pin neither.
Enabled only :disabled keeps the sheet's own chrome and
already renders legibly. .default is GTK's dialog default and
adw-gtk3 styles it as an accent button, which is why it shares
the suggested chip rather than inheriting a plain one. */
button.suggested-action, button.suggested-action label,
button.destructive-action, button.destructive-action label,
button.default, button.default label {
color: #${base16.base00};
}
button.suggested-action:not(.flat):not(:disabled),
button.default:not(.flat):not(:disabled) {
background-color: ${chip "@accent_bg_color" "0.70"};
}
button.suggested-action:not(.flat):not(:disabled):hover,
button.default:not(.flat):not(:disabled):hover {
background-color: ${chip "@accent_bg_color" "0.65"};
}
button.suggested-action:not(.flat):not(:disabled):active,
button.suggested-action:not(.flat):not(:disabled):checked,
button.default:not(.flat):not(:disabled):active,
button.default:not(.flat):not(:disabled):checked {
background-color: ${chip "@accent_bg_color" "0.55"};
}
button.destructive-action:not(.flat):not(:disabled) {
background-color: ${chip "@destructive_bg_color" "0.70"};
}
button.destructive-action:not(.flat):not(:disabled):hover {
background-color: ${chip "@destructive_bg_color" "0.65"};
}
button.destructive-action:not(.flat):not(:disabled):active,
button.destructive-action:not(.flat):not(:disabled):checked {
background-color: ${chip "@destructive_bg_color" "0.55"};
}
button.suggested-action.flat, button.suggested-action.flat label {
color: #${base16.base0D};
}
button.destructive-action.flat, button.destructive-action.flat label {
color: #${base16.base08};
}
'';
};
qt.enable = true;
# No-op unless programs.zathura is on (viewers.nix enables it).
zathura.enable = true;
};
cursor = {
name = lib.mkDefault "Bibata-Modern-Classic";
package = lib.mkDefault pkgs.bibata-cursors;
size = lib.mkDefault 24;
};
# GTK/file-manager/rofi icon theme, resolved from the JSON in
# theme.nix (per-theme `icons`, else Papirus-Dark/Light by mode).
# Stylix sets gtk.iconTheme from this; both dark/light point at the
# already-mode-resolved name. The package is papirus by default and
# only unions in another pack when a theme's `icons` names one
# (theme.nix — opt-in, no default closure bloat).
icons = {
enable = true;
package = lib.mkDefault t.iconThemePackage;
dark = t.iconTheme;
light = t.iconTheme;
};
fonts = {
monospace = {
name = t.fonts.mono;
package = lib.mkDefault pkgs.nerd-fonts.jetbrains-mono;
};
sansSerif = {
name = t.fonts.ui;
package = lib.mkDefault pkgs.inter;
};
serif = {
name = t.fonts.ui;
package = lib.mkDefault pkgs.inter;
};
emoji = {
name = "Noto Color Emoji";
package = lib.mkDefault pkgs.noto-fonts-color-emoji;
};
sizes.terminal = t.fonts.size;
};
};
# GTK3 hides menu-item icons by default (gtk-menu-images was flipped
# off upstream in 3.10). Classic tray menus — nm-applet's Wi-Fi list,
# udiskie — are built as image menu items, so without this their icons
# silently vanish. Stylix drives the HM gtk module, so extraConfig
# merges into the same settings.ini it already writes.
gtk.gtk3.extraConfig = {
"gtk-menu-images" = 1;
# Pair with adw-gtk3-dark so the theme sheet itself is dark, not just
# the @define-color recolor (see gtk.theme.name below).
"gtk-application-prefer-dark-theme" = t.mode != "light";
};
gtk.gtk4.extraConfig."gtk-application-prefer-dark-theme" = t.mode != "light";
# #98: Stylix hardcodes gtk.theme.name = "adw-gtk3" (light CSS) for every
# polarity and only injects libadwaita colour tokens. The light adw-gtk3
# sheet assumes light chrome and hardcodes near-black button labels in
# places — on Boreal that is black text on dark dialog buttons. Force
# the matching dark/light sheet; the package still provides both names.
gtk.theme = {
package = lib.mkDefault pkgs.adw-gtk3;
name = lib.mkForce (if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark");
};
# GTK4/libadwaita and Qt6 apps decide dark vs light from the XDG
# portal's color-scheme (org.freedesktop.appearance), which
# xdg-desktop-portal-gtk sources from this gsettings key. Stylix sets
# the GTK theme and `polarity` but not this, so a light theme still
# rendered libadwaita/Qt apps dark (and vice-versa). Drive it from the
# palette mode. (programs.dconf.enable is already on system-side.)
dconf.settings."org/gnome/desktop/interface" = {
color-scheme =
if t.mode == "light" then "prefer-light" else "prefer-dark";
gtk-theme =
if t.mode == "light" then "adw-gtk3" else "adw-gtk3-dark";
};
};
}