# 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) — dialog-scoped ONLY, never a bare `button` / `button label`. This file is loaded by every GTK3 process including Waybar, and a direct `button label` rule beats the color waybar.css puts on `#workspaces button` (a rule matching the label node always outranks color inherited from the button), so the old bare pin painted the workspace digits with whatever palette was live when the bar process started — the 2026-07-19 stale-digit bug: switch themes, digits keep the previous gtk.css color until the bar restarts. The real #98 fix is forcing adw-gtk3-dark (below); these pins are only the dialog safety net for a light sheet slipping in. */ .dialog-action-area button, .dialog-action-area button label, messagedialog button, messagedialog button label, .message-dialog button, .message-dialog button label { color: #${base16.base05}; } .dialog-action-area button:disabled, .dialog-action-area button:disabled label, messagedialog button:disabled, messagedialog button:disabled label, .message-dialog button:disabled, .message-dialog 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"; }; }; }