Compare commits
9 Commits
cb659ebb4c
...
5b93b97191
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b93b97191 | ||
|
|
ad6b76e1eb | ||
|
|
3a874dccc8 | ||
|
|
82776d7da4 | ||
|
|
34362d6a92 | ||
|
|
a640de4fd4 | ||
|
|
bd6d94f973 | ||
|
|
3d324982b9 | ||
|
|
61d9ee1577 |
@@ -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**
|
||||
|
||||
@@ -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 username’s uid/gid (or `id` after user create).
|
||||
Cost: small; V0–V1.
|
||||
|
||||
### 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; V0–V1.
|
||||
|
||||
### 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 don’t “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; don’t 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;
|
||||
V1–V2.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ·
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 **WirePlumber’s
|
||||
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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.0–2.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
|
||||
|
||||
13
flake.nix
13
flake.nix
@@ -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
26
lib.nix
@@ -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}";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
84
modules/theme-state-read.nix
Normal file
84
modules/theme-state-read.nix
Normal 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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"#CE5D97",
|
||||
"#3AA99F",
|
||||
"#FFFCF0",
|
||||
"#100F0F",
|
||||
"#6F6E69",
|
||||
"#D14D41",
|
||||
"#879A39",
|
||||
"#D0A215",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"surface": "#3b4252",
|
||||
"overlay": "#4c566a",
|
||||
"text": "#d8dee9",
|
||||
"subtext": "#e5e9f0",
|
||||
"subtext": "#b7becc",
|
||||
"muted": "#616e88",
|
||||
"accent": "#81a1c1",
|
||||
"accentAlt": "#b48ead",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"#23372B",
|
||||
"#FF5345",
|
||||
"#549e6a",
|
||||
"#459451",
|
||||
"#E5C736",
|
||||
"#509475",
|
||||
"#D2689C",
|
||||
"#2DD5B7",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"colors": {
|
||||
"base": "#05182e",
|
||||
"mantle": "#041427",
|
||||
"surface": "#00172e",
|
||||
"surface": "#0a2540",
|
||||
"overlay": "#134e5a",
|
||||
"text": "#f6dcac",
|
||||
"subtext": "#a7c9c6",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"surface": "#675e5f",
|
||||
"overlay": "#948a8b",
|
||||
"text": "#e6d9db",
|
||||
"subtext": "#e6d9db",
|
||||
"subtext": "#c3b7b8",
|
||||
"muted": "#948a8b",
|
||||
"accent": "#f38d70",
|
||||
"accentAlt": "#a8a9eb",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"#9b9b9b",
|
||||
"#b0b0b0",
|
||||
"#ececec",
|
||||
"#fdfdfd",
|
||||
"#666666",
|
||||
"#a4a4a4",
|
||||
"#b6b6b6",
|
||||
"#cecece",
|
||||
|
||||
@@ -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
96
tools/check-template-sot.py
Executable 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())
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user