Compare commits

...

9 Commits

Author SHA1 Message Date
Bernardo Magri
5b93b97191 docs(agent): close NEXT #64–#73 after parallel land
Some checks failed
Check / eval (push) Has been cancelled
Landed worktree batch: migration docs, installer chown, theme-state
reader, theme fidelity, audit/import tools, IR docs, template-sot check,
hardware MOTD tips. Verified contrast + template-sot + flake check.
2026-07-10 09:39:59 +01:00
Bernardo Magri
ad6b76e1eb feat(hints): #73 fingerprint + doctor MOTD/first-boot tips
#43 shipped Firmware MOTD + control-center --first-boot tip. Residual:
- MOTD always lists nomarchy-doctor; Fingerprint line only when
  nomarchy.hardware.fingerprint.enable (no nag without a reader).
- first-boot tips self-gate on fprintd-list / nomarchy-doctor (same
  gum style as Firmware). No dismiss state file — first-boot is once.

V0 flake check --no-build + bash -n; V1 build control-center + eval
MOTD with/without fingerprint.enable. V3 enroll stays hardware-queue.
2026-07-10 09:39:10 +01:00
Bernardo Magri
3a874dccc8 test(install): #72 installer ↔ template SoT parity check
Guard that nomarchy-install share/template ships byte-identical
flake.nix/system.nix/home.nix/theme-state.json to templates/downstream
(the single machine-seed SoT; default.nix copy list).

- tools/check-template-sot.py — share vs SoT file-set + content
- checks.template-sot — pure python + package path (theme-contrast pattern)
- BACKLOG #72 removed; JOURNAL entry

Verified (V0):
  python3 tools/check-template-sot.py <share/template> templates/downstream
  nix build .#checks.x86_64-linux.template-sot
2026-07-10 09:39:10 +01:00
Bernardo Magri
82776d7da4 docs(webcam): portal/Flatpak IR gap is v4l2-only (#71)
Ship the recommended document-only path: WirePlumber hideIrSensor acts
on the v4l2 monitor only; libcamera / xdg-desktop-portal / Flatpak
camera pickers can still list the internal IR sensor.

- HARDWARE.md §7: dual-sensor subsection (scope, workaround, deferred)
- ROADMAP § Webcam: ✓ #71; (b)/(c) stay hardware-gated
- README option row + HARDWARE-QUEUE T14s check for (b)/(c)
- Close BACKLOG #71; no libcamera rules

Verified: V0 (docs only).
2026-07-10 09:39:10 +01:00
Bernardo Magri
34362d6a92 fix(tools): identity audit exemptions + import hierarchy (#69 #70)
#69: audit-theme-design tags hue/CVD/ANSI-family noise on white,
vantablack, lumon, hackerman, matte-black, miasma as [identity] with a
legend — do not retune identity palettes into traffic lights.

#70: import-palettes keeps surface≠overlay when color0==color8, derives
light UI chips from base (ANSI-black trap), gentler light mantle; roles
are first-class. No bulk re-import of shipped theme JSON.

Verified: V0 (py_compile + synthetic import fixtures + audit report).
2026-07-10 09:39:10 +01:00
Bernardo Magri
a640de4fd4 fix(theme): #66 fail-closed theme-state errors at mkFlake/read
Missing, empty, or non-object theme-state.json now throws a short
message pointing at the template and `nomarchy-theme-sync validate`
instead of a raw readFile/fromJSON stack. Shared reader:
modules/theme-state-read.nix — wired in theme.nix, plymouth/timezone/
hardware, and lib.mkFlake (seq-forced early gate). Field-level schema
in theme.nix kept; unknown border roles still fail there; resolveColor
no longer falls through on missing palette keys. Defaults added for
settings.displayProfile / displayProfileAuto.

Verified: V0 nix flake check --no-build; negative evals (missing,
empty, [], bad border role); good-path template mode + displayProfile
defaults.
2026-07-10 09:39:10 +01:00
Bernardo Magri
bd6d94f973 fix(themes): fidelity hierarchy nits + #52 residual ANSI (#67, #68)
#67 hierarchy: nord subtext dimmer than text; tokyo-night btop Night
blues; ethereal surface raise + muted darker than overlay; retro-82
surface raised; ristretto subtext distinct + btop main_bg align;
flexoki-light distinct bright-black; gruvbox overlay/subtext ladder
(no rename).

#68 residual ANSI: vantablack ansi[8]=muted; osaka-jade ansi[3]=warn.

Verified: python3 tools/check-theme-contrast.py themes/ → 24×7 all pass.
2026-07-10 09:39:03 +01:00
Bernardo Magri
3d324982b9 fix(install): chown flake dir with real install-user uid/gid (#65)
Hard-coded 1000:100 assumed the first free normal uid and users(100).
After nixos-install, resolve id -u/-g for USERNAME on the target and
chown FLAKE_DIR (and the HM-fail hint file) to those ids.
2026-07-10 09:39:03 +01:00
Bernardo Magri
61d9ee1577 docs(migration): clarify installer vs @home-snapshots layout (#64)
Installer: disko @snapshots → /.snapshots + first-boot nested
/home/.snapshots. Migration machines may use top-level
@home-snapshots (TuringMachine); either works for snapper.
2026-07-10 09:39:03 +01:00
31 changed files with 546 additions and 120 deletions

View File

@@ -279,7 +279,7 @@ option, e.g. `services.easyeffects.enable = lib.mkForce false;` — see
| `nomarchy.hardware.fingerprint.pam` | `false` | Opt-in: use the fingerprint for login + sudo (PAM) |
| `nomarchy.hardware.npu.enable` | `false` | Opt-in/experimental: load the on-die NPU driver (`amdxdna`/`intel_vpu`); userspace runtime is BYO |
| `nomarchy.hardware.latestKernel` | `false` | Opt-in: ship `linuxPackages_latest` instead of the default kernel — for very new hardware whose drivers landed recently |
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex |
| `nomarchy.hardware.camera.hideIrSensor` | `false` | Hide a dual-sensor webcam's IR node from PipeWire's **v4l2** path so apps only ever see the colour camera (the "second, dark Integrated Camera"); installer-set on a paired RGB+IR webcam. `/dev/video*` stays open, so Howdy-style face unlock still works; `.irMatch` overrides the IR-name regex. Does **not** hide IR from libcamera / portal / Flatpak pickers — see [HARDWARE.md §7](docs/HARDWARE.md) |
| `nomarchy.hardware.i2c.enable` | `false` | I2C devices support — access to `/dev/i2c-*` (RGB controllers, sensors, DDC/CI monitor control) |
| `nomarchy.hardware.i2c.ddcci` | `false` (distro default: **`true`**) | the ddcci-driver kernel module, exposing external monitors as standard backlight devices via DDC/CI so brightness keys and swayosd natively control them |
| `nomarchy.services.tailscale.enable` | `false` | Opt-in: Tailscale mesh VPN — the login user is made the operator, so `tailscale up/down/set` and the System VPN menu work without sudo |
@@ -446,7 +446,10 @@ reload # exec zsh (reload the shell)
- **New themed value:** add the key to `theme-state.json` and consume it in
the Nix modules. One place — there is no second renderer to keep in sync.
- **Importing more old-distro palettes:**
`tools/import-palettes.py <palettes-dir> themes/`.
`tools/import-palettes.py <palettes-dir> themes/` (roles are first-class:
when ANSI color0==color8 the tool derives an overlay step; light themes
do not use dark ANSI black as surface — hand-tune after import; do not
bulk-reimport shipped JSON without a hierarchy pass).
- **New opt-in feature (convention):** when a feature is off by default and
needs the user to set `nomarchy.*` options (e.g. night light, per-device
keyboard layouts, monitor layout, power management), ship a **commented**

View File

@@ -28,60 +28,6 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in
## NEXT
### 64. MIGRATION.md snapshot layout vs installer
Installer creates disko `@snapshots``/.snapshots` and a first-boot
oneshot for nested `/home/.snapshots`. MIGRATION still speaks of
top-level `@home-snapshots` (TuringMachine vocabulary). One paragraph
clarifying installer layout vs migration machines. Cost: docs only; V0.
### 65. Installer soft gap: chown flake dir
`chown -R 1000:100` on the generated flake assumes first-user UID/GID.
Use the real install usernames uid/gid (or `id` after user create).
Cost: small; V0V1.
### 66. Fail-closed / friendlier theme-state errors in `mkFlake`
Missing or invalid `theme-state.json` should fail early with a pointer
to schema/docs, not a raw `readFile`/`fromJSON` stack. Related:
`settings.displayProfile*` defaults; unknown border role. Cost: medium
lib UX; V0V1.
### 67. Theme fidelity / hierarchy nits (popular ports)
gruvbox Material vs classic name+btop split; nord inverted subtext/text;
tokyo-night btop warm fg; ethereal surface=base; retro-82 surface
darker than base; ristretto subtext=text + btop bg drift; flexoki-light
bright-black. Cost: small JSON/btop each; V0 contrast + V1.
### 68. #52 residual ANSI
**vantablack** `ansi[8]` still `#fdfdfd` (diverges from `muted #666666`);
**osaka-jade** `ansi[3]` still warn-green `#459451` while `warn` is
`#E5C736`. Treat as fidelity bugs (align with muted/warn). Cost: tiny
JSON; V0 contrast.
### 69. audit-theme-design identity exemptions
Document expected CVD/hue noise for white/vantablack/lumon/hackerman/
matte-black/miasma so audits dont “fix” identity into traffic lights.
Optional special-case in the report script. Cost: docs or small script; V0.
### 70. Import pipeline hierarchy when ANSI 0==8
`tools/import-palettes.py` maps surface←color0, overlay←color8; when
0==8 (gruvbox, everforest) surface=overlay; light themes get sludge
mantle. Allow role overrides independent of ANSI; dont re-import
without a hierarchy pass. Cost: tool + convention; V0.
### 71. Portal/Flatpak IR camera — document (a)
Ship the recommended path only: document that the WirePlumber *v4l2*
IR-hide does not cover libcamera/Flatpak pickers (ROADMAP § Webcam).
Leave (b)/(c) engineering for a T14s session. Cost: docs; V0.
### 72. Installer ↔ template SoT — CI parity check
Optional pure check that the install share ships the same template
files as `templates/downstream`. Cost: small tool + flake check; V0.
### 73. Post-install hardware hints residual
#43 shipped Firmware MOTD/tip. Still open: fingerprint + doctor
one-liners (once or until dismissed). Cost: small greeter/MOTD/notify;
V1V2.
### 74. Unattended-install test matrix
`test-install.sh` always LUKS+swap; add (or document) no-swap and
explicit no-LUKS paths via env flags only. Cost: script + KVM time; V2.

View File

@@ -377,3 +377,8 @@ QA machine), the **T14s** (webcam case).
RGB+IR pair, bakes `hardware.camera.hideIrSensor`; `wpctl status`
shows one colour source; an app picker lists one camera; Howdy-style
direct `/dev/video2` reads still work.
- [ ] **Portal/Flatpak libcamera IR (b)/(c)** — after #71 docs: on hardware,
confirm a Flatpak/portal picker still lists the internal IR node;
only then investigate (b) WirePlumber libcamera GREY-only monitor
rule or (c) libcamera/udev-layer hide. Do not ship either without a
live dual-sensor check (see HARDWARE.md §7 / ROADMAP § Webcam).

View File

@@ -17,6 +17,17 @@ Template:
---
## 2026-07-10 — Loop batch land #64#73 (parallel worktrees)
- **Task:** agent-actionable NEXT #64#73 via worktree agents; land on main.
- **Did:** #64 MIGRATION snapshots · #65 real-uid chown · #66 theme-state
fail-closed reader · #67/#68 theme fidelity+ANSI · #69/#70 audit+import
tools · #71 IR portal docs · #72 template-sot check · #73 MOTD/first-boot
fingerprint+doctor tips. Agents did not push; parent cherry-picked
(stripped agent BACKLOG races). Closed #64#73.
- **Verified:** **V0/V1** — theme-contrast 24×7 pass; template-sot green;
bash -n install; flake check --no-build green.
- **Next suggestion:** #74 unattended matrix + #75 V2 install (KVM).
## 2026-07-10 — Promote agent-actionable PROPOSED → NEXT #64#75
- **Task:** Bernardo: bump non-hw/non-human PROPOSED categories 1+2.
- **Did:** **#64** MIGRATION snapshots · **#65** installer chown ·

View File

@@ -71,6 +71,12 @@ iteration would otherwise rediscover.
- `theme-state.json` is git-tracked inside an 86 MB flake tree, so every
state write re-copies the source before eval — the wallpapers-artifact
split (BACKLOG LATER) is the decided fix (§ Faster switches).
- **Friendly theme-state load** (`modules/theme-state-read.nix`, #66):
`builtins.tryEval` does **not** catch `readFile`/`fromJSON` failures —
gate with `pathExists` + empty/non-object checks before `fromJSON`.
Subtle JSON syntax errors still surface from nlohmann (line/col);
field schema stays in `theme.nix`. mkFlake must `builtins.seq` the
check onto the whole return set or lazy attr access skips it.
## Design invariants
- **Waybar status is never color-only** (item 28 sweep, iteration #69):
@@ -80,6 +86,14 @@ iteration would otherwise rediscover.
the module on it; a new `class` that only recolors an existing glyph is
a regression. Suppressed notification states (DND *and* app-inhibited)
all use the bell-off glyph + @muted.
- **Identity themes are not traffic lights** (#69): white, vantablack,
lumon, hackerman, matte-black, miasma — monochrome / mono-hue / earthy
status by design. `audit-theme-design.py` tags their hue/CVD/ANSI-family
findings `[identity]`; do not "fix" them into R/Y/G.
- **Import hierarchy ≠ ANSI** (#70): `import-palettes.py` must not set
surface==overlay when color0==color8; light color0 is often ANSI black
(not a chip). Roles are first-class — never bulk-reimport shipped JSON
without a hierarchy pass.
## Gotchas (cost a debugging session once)
- Waybar `layer: top` renders above **even real-fullscreen windows** — the

View File

@@ -120,9 +120,10 @@ Disable on VMs/headless: `services.fwupd.enable = false;` in `system.nix`.
| Present | Missing |
|---------|---------|
| Daemon + package | First-boot / MOTD hint |
| README one-liner | Doctor check (“updates available”) |
| **System ▸ Firmware menu** (`nomarchy-menu firmware`) | Waybar / updates panel integration |
| Daemon + package | Doctor check (“updates available”) |
| README one-liner | Waybar / updates panel integration |
| **System ▸ Firmware menu** (`nomarchy-menu firmware`) | |
| MOTD + first-boot tip (#43) | |
**System ▸ Firmware** (shipped): a self-gated System-submenu row (present
whenever `fwupdmgr` is on PATH, i.e. `services.fwupd.enable`, default-on)
@@ -131,8 +132,13 @@ confirm → `fwupdmgr update`. It never auto-flashes — `fwupdmgr update`
confirms each device and prompts for the reboot a capsule needs; pillar 1
(rock-stable) forbids silent BIOS writes.
**Still queued:** a post-install / MOTD one-liner when `fwupd` is active,
a Doctor “updates available” check, and Waybar/updates-panel integration.
**Hints (#43 / #73):** MOTD cheat-sheet line + `nomarchy-control-center
--first-boot` tip when `fwupdmgr` is on PATH. Fingerprint / doctor tips
follow the same pattern (fingerprint MOTD only when
`nomarchy.hardware.fingerprint.enable`).
**Still queued:** a Doctor “updates available” check, and
Waybar/updates-panel integration.
### Thunderbolt
@@ -170,6 +176,10 @@ on PATH) — Enroll / List / Verify / Delete all, plus **Use for login**
which writes `settings.fingerprint.pam` and applies on the next
`sys-rebuild` (option default follows theme-state.json).
**Hints (#73):** MOTD line when `fingerprint.enable` is on; first-boot
tip when `fprintd-list` is on PATH (`SUPER+M → System Fingerprint` /
`fprintd-enroll`). No permanent nag without a reader.
```sh
# CLI still works:
fprintd-enroll
@@ -250,6 +260,36 @@ needs a maintainer + hardware-queue coverage.
| Model quirks missing | Set better `hardwareProfile` (see §8) | System |
| OpenRGB / printing / Steam | `nomarchy.services.*` | System |
### Dual-sensor webcam / IR hide
Many dual-sensor modules (e.g. ThinkPad T14s) expose colour + IR as two
identically named “Integrated Camera” nodes. Picking the IR node yields a
black/dark greyscale frame — the classic “my webcam is dark” symptom.
`nomarchy.hardware.camera.hideIrSensor` (installer-on when RGB+IR names
are detected; overridable `irMatch`) drops the IR node from **WirePlumbers
v4l2 monitor** so native PipeWire pickers only offer the colour camera.
The kernel `/dev/video*` node stays open, so Howdy-style face unlock still
works. Design history: [ROADMAP § Webcam](ROADMAP.md).
**What it does not cover:** apps that list cameras via **libcamera** or
the **xdg-desktop-portal** / **Flatpak** camera path still see both
sensors — a Flatpak Zoom (or similar) user can still pick the black IR
“camera”. That is intentional: a blanket libcamera disable would risk
external USB cams that need the libcamera path, and surgical internal-only
libcamera rules could not match early enough (only `device.api` binds
before the WirePlumber monitor rule).
**Workaround:** prefer the colour device in the picker (or any non-IR
name). Apps that default to the first v4l2 colour source without a picker
are fine.
**Further engineering (needs T14s-class hardware):** (b) a WirePlumber
*libcamera* monitor rule disabling GREY-only nodes; (c) a libcamera/udev
quirk at the libcamera layer. Neither is implemented — the recommended
path is document-only until those can be verified on real dual-sensor
hardware (see `agent/HARDWARE-QUEUE.md` T14s).
Theme switches never touch drivers (Home Manager only).
## 8. Unsupported or unlisted machines
@@ -364,7 +404,7 @@ Full tables: [README § options](../README.md). Hardware-shaped surface:
| `nomarchy.hardware.fingerprint.enable` / `.pam` | fprintd + PAM |
| `nomarchy.hardware.npu.enable` | in-kernel NPU only |
| `nomarchy.hardware.latestKernel` | `linuxPackages_latest` |
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams |
| `nomarchy.hardware.camera.hideIrSensor` / `.irMatch` | dual-sensor webcams (v4l2 only; §7) |
| `nomarchy.hardware.i2c.enable` / `.ddcci` | I2C + external backlight |
| `nomarchy.system.power.*` | PPD/TLP, laptop, thermald, charge limit |
| `nomarchy.system.bluetooth.enable` | BT stack |

View File

@@ -30,6 +30,17 @@ assumptions. Check each:
| **LUKS** (optional, themed prompt) | `lsblk -f` shows `crypto_LUKS` | none — LUKS is optional |
| Already a **flake** config | `test -f /etc/nixos/flake.nix` | fine either way; you'll write a fresh flake regardless |
**Installer vs migration snapshot layout.** A fresh Nomarchy install
(disko) creates a top-level `@snapshots` subvolume mounted at
`/.snapshots`, then a first-boot oneshot makes a *nested*
`/home/.snapshots` under `@home` for snapper's home timeline — it does
**not** create a separate top-level `@home-snapshots`. Migration machines
(e.g. TuringMachine) may already use top-level `@snapshots` **and**
`@home-snapshots`; that is fine. Snapper only needs a `.snapshots` path
under each tracked subvolume (`/` and `/home`), so either layout works —
reuse what you have, or create the missing pieces if you want snapper
without reformatting.
TuringMachine matches all of these, including the `@snapshots` /
`@home-snapshots` subvolumes — so snapper works with zero disk work.

View File

@@ -539,10 +539,15 @@ Design/decision records and a running log of shipped work (items marked
generated drop-in's serialized content is valid WP-0.5 config; and the exact
shipped `irMatch` was re-confirmed live (1 V4L2 source = the colour
`/dev/video0`, libcamera untouched, IR node disabled in the WirePlumber log).
Remaining: an on-Nomarchy end-to-end check; optional `v4l-utils` +
`cameractrls` for the rare genuine-tuning case; and a follow-up for
portal/Flatpak apps that consume the libcamera path (where the internal IR is
still listed, since libcamera stays on).
Remaining: an on-Nomarchy end-to-end check (HARDWARE-QUEUE T14s);
optional `v4l-utils` + `cameractrls` already commented in the template.
- ✓ **Portal/Flatpak IR gap documented (#71, docs only):** the shipped
v4l2 IR-hide does **not** cover libcamera / xdg-desktop-portal /
Flatpak camera pickers (IR can still appear there). User-facing note
in [`HARDWARE.md` §7](HARDWARE.md). Engineering options (b) WirePlumber
libcamera GREY-only rule and (c) libcamera/udev-layer hide stay
deferred — both need a T14s-class dual-sensor machine to verify; no
libcamera rules in-tree.
- ✓ **Memory-pressure protection (earlyoom, default-on):**
`modules/nixos/oom.nix` — running out of memory kills the offending
process instead of freezing the desktop. **earlyoom over systemd-oomd,
@@ -877,6 +882,13 @@ Design/decision records and a running log of shipped work (items marked
cover all three generated surfaces. Status glyph accents
(good/warn/bad on base, 2.02.7 in some palettes) were left as-is —
raising them is palette design, a human call.
- ✓ **Identity audit exemptions + import hierarchy** (#69/#70):
`tools/audit-theme-design.py` tags expected hue/CVD/ANSI-family noise
on white/vantablack/lumon/hackerman/matte-black/miasma as `[identity]`
(do not retune into traffic lights). `tools/import-palettes.py` keeps
surface≠overlay when ANSI color0==color8, derives light chips from base
instead of ANSI black, and documents that roles are first-class — no
bulk re-import of shipped themes without a hierarchy pass.
- ✓ **Waybar crash on theme switch left the session bar-less** (Latitude
hardware QA, 2026-07-04): exec-once has no supervisor, so any crash
orphaned the session until relogin — and the switch path itself was

View File

@@ -294,6 +294,19 @@
touch $out
'';
# Installer share ↔ templates/downstream SoT (item 72): the four
# files default.nix copies into share/.../template/ must stay
# byte-identical to the checkout template (single machine-seed
# SoT; no thinner second catalog). Cheap package build + cmp.
template-sot = pkgs.runCommand "nomarchy-template-sot"
{ nativeBuildInputs = [ pkgs.python3 ]; }
''
python3 ${./tools/check-template-sot.py} \
${nomarchyInstall}/share/nomarchy-install/template \
${./templates/downstream}
touch $out
'';
# The installer's disko arg contract (item 49): swapSize "0"/"0G"
# (bare "0" is what the swap=0 install passes) must yield NO @swap
# subvolume, a sized value a correctly-sized one, and withLuks must

26
lib.nix
View File

@@ -5,7 +5,16 @@
# in flake.nix remain the escape hatch for power users.
{ nixpkgs, home-manager, nixos-hardware, nomarchy }:
let
inherit (nixpkgs) lib;
# Shared pure reader (modules/theme-state-read.nix) — re-exported so
# power users composing without mkFlake can call it too.
readThemeState = import ./modules/theme-state-read.nix { inherit lib; };
in
{
inherit readThemeState;
mkFlake =
{ src # the downstream flake directory (./.)
, username # login name; also the homeConfigurations attr
@@ -13,8 +22,6 @@
, system ? "x86_64-linux"
}:
let
inherit (nixpkgs) lib;
# One profile or several (the installer's autodetection emits a few
# common-* modules alongside the model-specific one).
profileNames =
@@ -51,8 +58,17 @@
# and the standalone HM desktop see the same package set.
config.allowUnfree = true;
};
# Early fail-closed gate: missing/empty/non-object theme-state.json
# throws here with a template + validate pointer, before module
# evaluation buries a raw readFile/fromJSON stack. Field-level
# schema still runs in modules/home/theme.nix after defaults merge.
# Forced via seq on the whole return set — attrNames alone must not
# skip the check (Nix is lazy on unused let bindings and attr values).
statePath = src + "/theme-state.json";
_themeState = readThemeState statePath;
in
{
builtins.seq _themeState {
# System layer — rebuilt rarely:
# sudo nixos-rebuild switch --flake .#default
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
@@ -67,7 +83,7 @@
{ environment.systemPackages = [ home-manager.packages.${system}.home-manager ]; }
# System-side theme consumers (Plymouth splash background)
# read the same JSON the desktop does.
{ nomarchy.system.stateFile = src + "/theme-state.json"; }
{ nomarchy.system.stateFile = statePath; }
]
++ hardwareModules
++ [
@@ -87,7 +103,7 @@
{
# Written by nomarchy-theme-sync; reading it is pure — the
# file is part of the downstream flake's source.
nomarchy.stateFile = src + "/theme-state.json";
nomarchy.stateFile = statePath;
home = {
inherit username;
homeDirectory = "/home/${username}";

View File

@@ -18,7 +18,11 @@
let
cfg = config.nomarchy;
themeState = builtins.fromJSON (builtins.readFile cfg.stateFile);
# Fail-closed load: missing / empty / non-object get a short pointer at
# the template + `nomarchy-theme-sync validate`, not a raw readFile stack
# from deep inside a consumer (nightlight, hyprland, …). Field-level
# checks below still run on the merged result.
themeState = import ../theme-state-read.nix { inherit lib; } cfg.stateFile;
# Defaults guarantee evaluation succeeds on a sparse or older state
# file (e.g. one written before a schema field was added). The shipped
@@ -64,6 +68,8 @@ let
# rebuild. settings.monitors: per-output resolutions the Display menu
# remembers (output-name -> "WxH@R"), overlaid onto nomarchy.monitors by
# name in hyprland.nix — the monitor twin of the keyboard graduation.
# settings.displayProfile / displayProfileAuto: active named layout +
# auto-switch on plug events (hyprland.nix / Display menu).
# Defaulted so a sparse/older state file still evaluates; nomarchy.settings
# exposes them.
settings = {
@@ -74,6 +80,8 @@ let
# service, but the flag lives here so both sides read one source — the
# home side gates the Waybar-refresh watcher (timezone.nix) on it.
autoTimezone = false;
displayProfile = "";
displayProfileAuto = false;
};
};
@@ -135,11 +143,18 @@ let
`nomarchy-theme-sync apply boreal`.'';
# A border value is a palette key (look it up in colors) unless it's
# already a literal hex; unknown keys fall through to the raw string.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
# already a literal hex. Unknown roles are rejected by the field checks
# above; resolve only runs on a validated state.
resolveColor = v:
if lib.hasPrefix "#" v then v
else parsed.colors.${v} or (throw ''
Nomarchy: border role "${v}" is not in the palette (colors.*).
Use one of: ${lib.concatStringsSep ", " colorRoles}
or a literal "#RRGGBB". Validate with: nomarchy-theme-sync validate'');
border = {
active = resolveColor parsed.border.active;
inactive = resolveColor parsed.border.inactive;
active = resolveColor checked.border.active;
inactive = resolveColor checked.border.inactive;
};
# Resolve the icon theme once and expose it on nomarchy.theme so both

View File

@@ -47,8 +47,10 @@ in
# MOTD on TTY/SSH login (the desktop auto-logs into Hyprland, so this
# is mostly seen over SSH or on a bare console). Branded, and doubles
# as a cheat sheet for the distro's own helpers.
users.motd = lib.mkDefault ''
# as a cheat sheet for the distro's own helpers. Fingerprint line only
# when fprintd is enabled (no permanent nag on machines without a
# reader); doctor is always on PATH via systemPackages.
users.motd = lib.mkDefault (''
${distroName} a NixOS desktop, themed from one JSON.
@@ -56,9 +58,12 @@ in
sys-rebuild rebuild the system, no input update
home-update apply home/theme changes (no sudo)
nomarchy-theme-sync apply <theme> switch the whole palette
nomarchy-doctor read-only health check
SUPER+? keybindings cheatsheet
SUPER+M System Firmware check LVFS firmware updates (fwupd)
'';
'' + lib.optionalString config.nomarchy.hardware.fingerprint.enable ''
SUPER+M System Fingerprint enroll a finger (fprintd)
'');
# Unfree allowed distro-wide: pragmatic-desktop territory (vendor
# GPU/wifi drivers, firmware, fonts, …). The custom nixpkgs-config

View File

@@ -16,10 +16,11 @@
let
cfg = config.nomarchy.hardware;
# Fingerprint PAM can follow theme-state.json (menu toggle → next
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55).
# sys-rebuild), same bridge as autoTimezone (BACKLOG #55). Missing or
# invalid JSON fails closed (theme-state-read.nix) instead of a raw stack.
hwState =
if config.nomarchy.system.stateFile != null
then builtins.fromJSON (builtins.readFile config.nomarchy.system.stateFile)
then import ../theme-state-read.nix { inherit lib; } config.nomarchy.system.stateFile
else { };
pamFromState = (hwState.settings or { }).fingerprint.pam or false;
in

View File

@@ -12,7 +12,7 @@ let
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
else { };
colorOf = key: fallback: lib.removePrefix "#" ((state.colors or { }).${key} or fallback);
# Fallbacks match the distro default theme (Boreal) when stateFile is null.

View File

@@ -15,10 +15,11 @@ let
cfg = config.nomarchy.system;
# Read the same state file the rest of the system side uses (Plymouth too),
# wired by lib.mkFlake. The flag defaults off when the file is absent/sparse.
# wired by lib.mkFlake. The flag defaults off when stateFile is null;
# a set-but-missing/invalid path fails closed via theme-state-read.nix.
state =
if cfg.stateFile != null
then builtins.fromJSON (builtins.readFile cfg.stateFile)
then import ../theme-state-read.nix { inherit lib; } cfg.stateFile
else { };
stateEnabled = (state.settings or { }).autoTimezone or false;

View File

@@ -0,0 +1,84 @@
# Pure theme-state.json loader — fail closed with a short, actionable
# message instead of a raw `readFile` / `fromJSON` stack buried in a
# consumer. Used by modules/home/theme.nix (required path), the NixOS
# stateFile consumers, and lib.mkFlake (early gate).
#
# Field-level schema checks stay in theme.nix (post-defaults). This file
# only gates *existence* and *JSON-shape* so the first failure the user
# sees points at the state file, not at nightlight.nix.
{ lib }:
path:
let
pathStr = toString path;
tip = ''
Fix:
Missing file copy the template next to your flake.nix:
templates/downstream/theme-state.json
or regenerate from a preset:
nomarchy-theme-sync apply boreal
Bad syntax / wrong shape edit theme-state.json (trailing commas
are the usual culprit) or reset with `apply` as above.
Field-level schema (colors, ui, border, ):
nomarchy-theme-sync validate
Eval-time field checks live in modules/home/theme.nix.'';
missingMsg = ''
Nomarchy: theme-state.json is missing:
${pathStr}
This file is required (appearance + menu settings). Add it to your
flake checkout so evaluation stays pure.
${tip}'';
emptyMsg = ''
Nomarchy: theme-state.json is empty:
${pathStr}
${tip}'';
notObjectMsg = ''
Nomarchy: theme-state.json must be a JSON object `{ ... }`:
${pathStr}
${tip}'';
# lib.trim / lib.strings.trim — strip leading/trailing whitespace so an
# all-whitespace file counts as empty and a BOM-less `{` is recognized.
strip = s:
let
# Prefer lib.trim when present (nixpkgs ≥ 23.11); fall back so a
# very old pin still gates missing/non-object.
trim =
if lib ? trim then lib.trim
else if lib.strings ? trim then lib.strings.trim
else (x: x);
in trim s;
in
if !(builtins.pathExists path) then
throw missingMsg
else
let
raw = builtins.readFile path;
stripped = strip raw;
in
if stripped == "" then
throw emptyMsg
# Reject non-objects before fromJSON where we can (null / array / string
# literals). Subtle syntax errors still surface from fromJSON itself —
# those messages already include line/column; the path tip above is the
# part a raw stack used to bury.
else if builtins.match "[[:space:]]*\\{.*" stripped == null then
throw notObjectMsg
else
let
value = builtins.fromJSON raw;
in
if !(builtins.isAttrs value) then
throw notObjectMsg
else
value

View File

@@ -39,10 +39,21 @@ function first_boot() {
fi
fi
# Post-install hardware/day-2 tips (#43 Firmware; #73 fingerprint +
# doctor). Self-gated on the same CLI presence the System menu uses;
# first-boot is once, so no dismiss state file is needed.
if command -v fwupdmgr >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: SUPER+M → System Firmware checks LVFS updates (never auto-flashes)."
fi
if command -v fprintd-list >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: SUPER+M → System Fingerprint to enroll a finger (or fprintd-enroll)."
fi
if command -v nomarchy-doctor >/dev/null 2>&1; then
gum style --foreground 245 \
"Tip: run nomarchy-doctor (or SUPER+M → System Doctor) for a read-only health check."
fi
gum style --foreground 212 "First boot configuration complete!"
echo "You can always change these later in the Control Center."

View File

@@ -496,13 +496,9 @@ fi
commit -qm "Initial Nomarchy configuration"
)
# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. The first normal NixOS user is always 1000:users(100); the
# account doesn't exist in the target yet, so numeric ids it is.
chown -R 1000:100 "$FLAKE_DIR"
# The templates come out of the nix store mode 0444 and cp preserves
# that — without this the user owns home.nix but can't edit it.
# that — without this the user can't edit home.nix after they own it.
# Ownership is applied after nixos-install (real uid/gid; see below).
chmod -R u+w "$FLAKE_DIR"
# /etc/nixos on the installed system points at the user-owned flake.
@@ -554,6 +550,17 @@ fi
nixos-install --no-root-passwd "${NIXOS_INSTALL_OPTS[@]}" --flake "path:$FLAKE_DIR#default"
success "System installed (bootloader in place)"
# The user must own their flake — libgit2 refuses repositories owned by
# someone else, which breaks `home-manager switch` (and theme switching)
# outright. Resolve real uid/gid from the target after nixos-install
# created the account (do not hard-code 1000:100 — first free uid or
# primary group can differ).
USER_UID=$(nixos-enter --root /mnt -- id -u "$USERNAME") \
|| fail "Could not resolve uid for install user '$USERNAME' on target"
USER_GID=$(nixos-enter --root /mnt -- id -g "$USERNAME") \
|| fail "Could not resolve gid for install user '$USERNAME' on target"
chown -R "$USER_UID:$USER_GID" "$FLAKE_DIR"
# Pre-activate the Home Manager generation so the FIRST boot lands in the
# fully themed desktop, not bare Hyprland. Best-effort: a failure here
# only costs the user one `home-manager switch` after logging in.
@@ -629,8 +636,8 @@ else
tail -n 5 /mnt/var/log/nomarchy-hm-preactivate.log 2>/dev/null || true
# The live session (and this warning) ends with this install — drop a
# durable hint on the TARGET so the fix still surfaces on first login.
# Numeric ids: the account doesn't exist in the live environment yet
# (same reasoning as the $FLAKE_DIR chown above).
# Numeric ids from the target account (USER_UID/USER_GID above) — the
# name does not exist in the live ISO's passwd.
hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"
cat > "$hint_file" <<HINT
Desktop pre-activation failed during install — see
@@ -640,7 +647,7 @@ Desktop pre-activation failed during install — see
(delete this file once done)
HINT
chown 1000:100 "$hint_file"
chown "$USER_UID:$USER_GID" "$hint_file"
fi
rm -f /mnt/root/nomarchy-hm-activate.sh

View File

@@ -7,11 +7,11 @@
"colors": {
"base": "#060B1E",
"mantle": "#05091a",
"surface": "#060B1E",
"surface": "#0e1836",
"overlay": "#6d7db6",
"text": "#ffcead",
"subtext": "#F99957",
"muted": "#6d7db6",
"muted": "#5568a0",
"accent": "#7d82d9",
"accentAlt": "#c89dc1",
"good": "#92a593",

View File

@@ -48,7 +48,7 @@
"#CE5D97",
"#3AA99F",
"#FFFCF0",
"#100F0F",
"#6F6E69",
"#D14D41",
"#879A39",
"#D0A215",

View File

@@ -8,9 +8,9 @@
"base": "#282828",
"mantle": "#222222",
"surface": "#3c3836",
"overlay": "#3c3836",
"overlay": "#504945",
"text": "#d4be98",
"subtext": "#d4be98",
"subtext": "#a89984",
"muted": "#7c6f64",
"accent": "#7daea3",
"accentAlt": "#d3869b",

View File

@@ -10,7 +10,7 @@
"surface": "#3b4252",
"overlay": "#4c566a",
"text": "#d8dee9",
"subtext": "#e5e9f0",
"subtext": "#b7becc",
"muted": "#616e88",
"accent": "#81a1c1",
"accentAlt": "#b48ead",

View File

@@ -43,7 +43,7 @@
"#23372B",
"#FF5345",
"#549e6a",
"#459451",
"#E5C736",
"#509475",
"#D2689C",
"#2DD5B7",

View File

@@ -7,7 +7,7 @@
"colors": {
"base": "#05182e",
"mantle": "#041427",
"surface": "#00172e",
"surface": "#0a2540",
"overlay": "#134e5a",
"text": "#f6dcac",
"subtext": "#a7c9c6",

View File

@@ -10,7 +10,7 @@
"surface": "#675e5f",
"overlay": "#948a8b",
"text": "#e6d9db",
"subtext": "#e6d9db",
"subtext": "#c3b7b8",
"muted": "#948a8b",
"accent": "#f38d70",
"accentAlt": "#a8a9eb",

View File

@@ -2,7 +2,7 @@
#Reconfigured from monokai theme
# Main background, empty for terminal default, need to be empty if you want transparent background
theme[main_bg]="#2c2421"
theme[main_bg]="#2c2525"
# Main text color
theme[main_fg]="#e6d9db"

View File

@@ -5,10 +5,10 @@
theme[main_bg]="#1a1b26"
# Main text color
theme[main_fg]="#cfc9c2"
theme[main_fg]="#a9b1d6"
# Title color for boxes
theme[title]="#cfc9c2"
theme[title]="#a9b1d6"
# Highlight color for keyboard shortcuts
theme[hi_fg]="#7dcfff"
@@ -17,7 +17,7 @@ theme[hi_fg]="#7dcfff"
theme[selected_bg]="#414868"
# Foreground color of selected item in processes box
theme[selected_fg]="#cfc9c2"
theme[selected_fg]="#a9b1d6"
# Color of inactive/disabled text
theme[inactive_fg]="#565f89"

View File

@@ -48,7 +48,7 @@
"#9b9b9b",
"#b0b0b0",
"#ececec",
"#fdfdfd",
"#666666",
"#a4a4a4",
"#b6b6b6",
"#cecece",

View File

@@ -4,6 +4,11 @@
Reports (never fails): OKLCH lightness architecture, extended WCAG
contrast pairs, hue-family sanity for status colors, accent harmony,
CVD (protanopia/deuteranopia) distinguishability, ANSI slot semantics.
Identity exemptions (#69): white, vantablack, lumon, hackerman,
matte-black, miasma deliberately reject traffic-light status hues.
Their hue / CVD / ANSI-family findings are tagged [identity] (expected
design noise), not bugs to "fix" into red/yellow/green.
"""
import json, math, sys
from pathlib import Path
@@ -65,8 +70,26 @@ def hue_in(H, lo, hi):
ROLES = ["base","mantle","surface","overlay","text","subtext","muted","accent","accentAlt","good","warn","bad"]
# Themes whose identity is monochrome / mono-hue / earthy — not traffic lights.
# Hue + CVD + ANSI family noise here is expected; do not retune into R/Y/G.
IDENTITY_THEMES = {
"white": "monochrome greys — status is L-steps, not traffic lights",
"vantablack": "monochrome greys — status is L-steps, not traffic lights",
"lumon": "Severance blue mono — good/warn/bad share one blue family",
"hackerman": "matrix green mono — warn/bad live in green/cyan",
"matte-black": "desaturated material accents — not traffic-light hues",
"miasma": "earthy swamp palette — bad is brown, not red",
}
# Categories re-tagged [identity] for the themes above (structural checks stay).
IDENTITY_OK_CATS = frozenset({"hue", "cvd"})
findings = {}
def note(slug, cat, msg):
def note(slug, cat, msg, *, identity_ok=False):
if identity_ok and slug in IDENTITY_THEMES and cat in IDENTITY_OK_CATS:
cat = "identity"
elif identity_ok and slug in IDENTITY_THEMES and cat == "ansi" and "hue" in msg:
# ANSI slot family mismatches on identity palettes are the same choice.
cat = "identity"
findings.setdefault(slug, []).append((cat, msg))
themes = sorted(Path(sys.argv[1] if len(sys.argv) > 1 else "themes").glob("*.json"))
@@ -106,9 +129,10 @@ for tf in themes:
h = H[role]
ok = hue_in(h, lo, hi) or (role == "bad" and hue_in(h, 330, 45))
if C[role] < 0.03:
note(slug, "hue", f"{role} is near-grey (C={C[role]:.3f}) — status color carries no hue")
note(slug, "hue", f"{role} is near-grey (C={C[role]:.3f}) — status color carries no hue",
identity_ok=True)
elif not ok:
note(slug, "hue", f"{role} hue {h:.0f}° outside {fam} family")
note(slug, "hue", f"{role} hue {h:.0f}° outside {fam} family", identity_ok=True)
dh = abs(H["accent"] - H["accentAlt"]); dh = min(dh, 360 - dh)
harm = "analogous" if dh < 60 else "triadic-ish" if dh < 150 else "complementary"
if 90 <= dh <= 150 and de(c["accent"], c["accentAlt"]) > 0.12:
@@ -119,7 +143,8 @@ for tf in themes:
for r1, r2 in [("good","bad"),("good","warn"),("accent","bad")]:
d = de(cvd_hex(c[r1], M), cvd_hex(c[r2], M))
if d < 0.09:
note(slug, "cvd", f"{r1}/{r2} nearly identical under {name} (dE={d:.03f})")
note(slug, "cvd", f"{r1}/{r2} nearly identical under {name} (dE={d:.03f})",
identity_ok=True)
# E. ANSI slot semantics (greeter/tty rely on them)
ansi = t.get("ansi", [])
@@ -129,7 +154,8 @@ for tf in themes:
for slot in (idx, idx+8):
Ls, Cs, Hs = oklch(ansi[slot])
if Cs >= 0.04 and not hue_in(Hs, lo, hi):
note(slug, "ansi", f"ansi[{slot}] hue {Hs:.0f}° not {fam}")
note(slug, "ansi", f"ansi[{slot}] hue {Hs:.0f}° not {fam}",
identity_ok=True)
if oklch(ansi[0])[0] > oklch(ansi[15])[0]:
note(slug, "ansi", "ansi[0] lighter than ansi[15] (inverted slots)")
rt = ratio(ansi[7], ansi[0])
@@ -137,12 +163,31 @@ for tf in themes:
note(slug, "ansi", f"ansi[7] on ansi[0] = {rt:.2f} (tuigreet text=gray on container=black)")
print(f"audited {len(themes)} themes\n")
if IDENTITY_THEMES:
print("identity exemptions (hue/CVD/ANSI-family expected, not traffic-light bugs):")
for slug in sorted(IDENTITY_THEMES):
print(f" {slug}: {IDENTITY_THEMES[slug]}")
print()
cats = {}
for slug in sorted(findings):
print(f"── {slug}")
id_note = IDENTITY_THEMES.get(slug)
header = f"── {slug}"
if id_note:
header += f" · identity: {id_note}"
print(header)
for cat, msg in findings[slug]:
cats[cat] = cats.get(cat, 0) + 1
print(f" [{cat}] {msg}")
# clean = no findings; identity-only = only [identity] tags remain
clean = [t.stem for t in themes if t.stem not in findings]
identity_only = []
for slug, items in findings.items():
if slug in IDENTITY_THEMES and all(cat == "identity" for cat, _ in items):
identity_only.append(slug)
print(f"\nclean: {', '.join(clean) if clean else '(none)'}")
if identity_only:
print(f"identity-only (expected noise): {', '.join(sorted(identity_only))}")
print("totals:", dict(sorted(cats.items())))

96
tools/check-template-sot.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Installer ↔ templates/downstream SoT parity (flake check `template-sot`).
Usage:
check-template-sot.py <install-share-template> <templates/downstream>
Asserts the files nomarchy-install ships under
$out/share/nomarchy-install/template/ are byte-identical to the same
names in templates/downstream — the single source of truth for machine
flake seed files (see pkgs/nomarchy-install/default.nix installPhase).
The package copies a fixed file list (not a recursive tree copy):
flake.nix system.nix home.nix theme-state.json
README.md and hardware-configuration.nix stay repo-only (docs /
nixos-generate-config at install time) and are intentionally absent
from the share.
Wired as `checks.template-sot` in flake.nix; also runnable after a local
package build:
share=$(nix build --no-link --print-out-paths .#nomarchy-install)
python3 tools/check-template-sot.py \
"$share/share/nomarchy-install/template" templates/downstream
"""
from __future__ import annotations
import sys
from pathlib import Path
# Must match the cp list in pkgs/nomarchy-install/default.nix installPhase.
SHIPPED = ("flake.nix", "system.nix", "home.nix", "theme-state.json")
def fail(msg: str) -> None:
print(f"template-sot: {msg}", file=sys.stderr)
sys.exit(1)
def main() -> int:
if len(sys.argv) != 3:
print(
f"usage: {sys.argv[0]} <install-share-template> <templates/downstream>",
file=sys.stderr,
)
return 2
share = Path(sys.argv[1])
sot = Path(sys.argv[2])
if not share.is_dir():
fail(f"install share template dir missing: {share}")
if not sot.is_dir():
fail(f"templates/downstream dir missing: {sot}")
share_names = sorted(p.name for p in share.iterdir() if p.is_file())
expected = sorted(SHIPPED)
if share_names != expected:
fail(
"install share template file set drifted from default.nix copy list:\n"
f" share: {share_names}\n"
f" expected: {expected}\n"
" (update SHIPPED here and the cp in pkgs/nomarchy-install/default.nix together)"
)
mismatches = []
for name in SHIPPED:
src = sot / name
dst = share / name
if not src.is_file():
fail(f"SoT missing shipped file: {src}")
a = src.read_bytes()
b = dst.read_bytes()
if a != b:
mismatches.append(
f"{name}: share ({len(b)} bytes) != SoT ({len(a)} bytes)"
)
if mismatches:
print("template-sot: content mismatch(es):", file=sys.stderr)
for m in mismatches:
print(f" {m}", file=sys.stderr)
return 1
print(
f"template-sot: {len(SHIPPED)} files match "
f"{sot}{share}"
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -15,6 +15,20 @@ Output, per theme:
<slug>.json the palette (Nomarchy theme schema)
<slug>/backgrounds/ wallpapers, copied verbatim
<slug>/btop.theme copied from apps/ when present
Hierarchy vs ANSI (#70):
Nomarchy roles (surface/overlay/mantle/…) are first-class and may
diverge from ansi[0]/ansi[8]. When color0==color8 (gruvbox, everforest,
many light themes' black==bright-black), do NOT collapse surface and
overlay — derive a raised step so the bg stack has hierarchy.
Light themes: ANSI color0 is almost always "black" (dark), not a UI
chip. Using it as surface paints sludge on a light canvas — detect
that trap and derive surface/overlay from base instead.
After import, hand-tune roles if needed. Do not bulk-reimport shipped
themes/*.json without a hierarchy pass — this tool is for *future*
imports / new palettes.
"""
import json
@@ -25,33 +39,109 @@ from pathlib import Path
def darken(hex_color: str, factor: float = 0.85) -> str:
"""Scale a #rrggbb color toward black (used to derive 'mantle')."""
"""Scale a #rrggbb color toward black (used to derive 'mantle' / light chips)."""
h = hex_color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
return "#{:02x}{:02x}{:02x}".format(*(round(v * factor) for v in (r, g, b)))
def lighten(hex_color: str, amount: float = 0.14) -> str:
"""Move each channel toward 255 by `amount` (0..1) — raised dark-mode chip."""
h = hex_color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) for i in (0, 2, 4))
return "#{:02x}{:02x}{:02x}".format(
*(max(0, min(255, round(v + (255 - v) * amount))) for v in (r, g, b))
)
def _norm(hex_color: str) -> str:
return hex_color.strip().lower()
def _luma(hex_color: str) -> float:
"""Relative luminance (sRGB), for light-theme ANSI-black detection."""
h = hex_color.lstrip("#")
r, g, b = (int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
def lin(c: float) -> float:
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
def hierarchy_roles(base: str, color0: str, color8: str, mode: str) -> dict:
"""Map ANSI → surface/overlay/mantle without collapsing when 0==8.
Roles stay independent of the ANSI table: even when color0==color8 in
the source palette, surface and overlay must differ so chips/menus
can step up the stack.
"""
# mantle: step "under" the canvas. Light bases use a gentler darken so
# cream/off-white does not become muddy grey sludge.
mantle = darken(base, 0.85 if mode == "dark" else 0.92)
surface, overlay = color0, color8
if mode == "light":
# Light-theme trap: color0/color8 are ANSI black / bright-black
# (dark), not raised UI chips. Derive chips off base instead.
base_L = _luma(base)
if _luma(surface) < base_L * 0.5:
surface = darken(base, 0.94)
if _luma(overlay) < base_L * 0.5:
overlay = darken(base, 0.88)
# When ANSI 0==8 (or the light-theme derive left them equal), force a
# hierarchy step: dark mode lightens overlay; light mode darkens it.
if _norm(surface) == _norm(overlay):
if mode == "dark":
overlay = lighten(surface, 0.14)
else:
overlay = darken(surface, 0.88)
print(f" note: color0==color8 — derived overlay hierarchy ({surface}{overlay})")
return {"mantle": mantle, "surface": surface, "overlay": overlay}
def convert(theme_dir: Path) -> dict:
toml = tomllib.loads((theme_dir / "colors.toml").read_text())
slug = theme_dir.name
ansi = [toml[f"color{i}"] for i in range(16)]
mode = "light" if (theme_dir / "light.mode").exists() else "dark"
base = toml["background"]
roles = hierarchy_roles(base, toml["color0"], toml["color8"], mode)
# muted: prefer color8 when it is a mid grey distinct from surface;
# otherwise derive so dim labels still read (roles ≠ ANSI when 0==8).
muted = toml["color8"]
if _norm(muted) == _norm(roles["surface"]) or (
mode == "light" and _luma(muted) < _luma(base) * 0.5
):
if mode == "dark":
muted = darken(toml["foreground"], 0.55)
else:
# mid grey between text and base
th, bh = toml["foreground"].lstrip("#"), base.lstrip("#")
muted = "#{:02x}{:02x}{:02x}".format(
*((int(th[i:i + 2], 16) + int(bh[i:i + 2], 16)) // 2 for i in (0, 2, 4))
)
return {
"version": 1,
"name": slug.replace("-", " ").title(),
"slug": slug,
"mode": "light" if (theme_dir / "light.mode").exists() else "dark",
"mode": mode,
# Empty = auto: nomarchy-theme-sync falls back to the first file in
# the theme's backgrounds/ directory.
"wallpaper": "",
"colors": {
"base": toml["background"],
"mantle": darken(toml["background"]),
"surface": toml["color0"],
"overlay": toml["color8"],
"base": base,
"mantle": roles["mantle"],
"surface": roles["surface"],
"overlay": roles["overlay"],
"text": toml["foreground"],
"subtext": toml["color7"],
"muted": toml["color8"],
"muted": muted,
"accent": toml["accent"],
"accentAlt": toml["color5"],
"good": toml["color2"],