You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
`nh os switch` failed on alcyone every second run, and not only for GTK
theming: `checkNewGenCollision || exit 1` gates the whole activate script, so
linkGeneration, dconf, systemd user-unit reloads, the repo's own activation
scripts and the current-generation GC root all stopped converging. The user's
entire home activation was wedged, with the build perfectly clean.
The seam ADR-048 left: Stylix's gtk target declared gtk.css as an HM-owned
read-only symlink, and Noctalia's gtk3/gtk4 templates materialised it into a
plain file with their own @import appended. That file always differs from HM's
source, so each switch moved it to gtk.css.hm-bak and the next one hit
home-manager's refusal to clobber an existing backup.
Disable the target's gtk.css write outright rather than forcing HM to win the
collision. `force = true` was implemented and reviewed first; it works, but the
link step is force-unaware and relinks every switch, resetting Noctalia's
colours until its next resolve. enable = false removes the collision instead of
winning it. Noctalia's ensure_gtk_css_import creates the file from nothing when
absent, so nothing depended on HM placing it first, and the target stays
enabled for settings.ini — what the G6 audit retained it for.
The premise correction is the larger half. ADR-048 accepted the @define-color
transient base as unavoidable collateral of keeping the gtk target: the G3
spike looked for an extraCss seam, did not find one, and concluded ownership
had to be shared. Home Manager's per-file enable toggle was there the whole
time, so the base was never forced on us. ADR-048's decision item 3 and its
activation-window consequence are amended with the as-accepted wording kept in
parentheticals, plus a History entry. That in-place body edit departs from the
immutable-body convention docs/decisions/README.md states and #872 applied to
ADR-018; it is operator-authorised, and History records it as deliberate so a
later reconciliation sweep does not read it as drift.
docs/desktop/polkit.md claimed a palette change rewrites gtk.css, now false —
two review lenses caught it independently. The unit's gtk.css restart trigger
is left in place but is vestigial; removing it is a behaviour change owed its
own decision.
Not yet runtime-verified: two consecutive switches with a Noctalia resolve
between them still need witnessing on metal, per the set-is-not-enforced rule.
Claude-Session: https://claude.ai/code/session_011c32rS7mBUc5ELVzyWNrUV
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/decisions/ADR-048-noctalia-theming-delegation.md
+10-2Lines changed: 10 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -19,7 +19,7 @@ The dialogue settled the user-facing model on the operator's terms: theming ties
19
19
20
20
**2. A whitelisted builtin-template set is enabled, exactly four ids.** `theme.templates.enable_builtin_templates = true` with `builtin_ids = [ "foot" "gtk3" "gtk4" "niri" ]` — whitelist-form, never blanket (CLAUDE.md stance). `starship` and `btop` are excluded: the G3 spike's starship incident found both tools' builtin `apply.sh` run `sed -i` against the discovered config path with no read-only awareness, materializing a plain file over an HM-owned symlink — a real, disclosed safety incident against the operator's live `STARSHIP_CONFIG`, remediated same-session (design note §De-risk). starship's constructive route is a future user-template with an explicit `output_path`, not this builtin. `helix` and the remaining community ids are deferred post-cutover pending their own G3-shaped acceptance testing. Qt has no target on this desktop (#103, ADR-028) and stays out. `theme.templates.enable_community_templates = false` — a separate, unreviewed trust surface (arbitrary `apply.sh` via `/bin/sh -lc`, no sandboxing) staged for after cutover; community **palettes** need no enablement at all and are part of the UX regardless, picked directly in Noctalia's own UI.
21
21
22
-
**3. Mount-points are pre-declared, not runtime-hooked.** `home/nixos/foot.nix` declares `include = "~/.config/foot/themes/noctalia"` (foot's builtin `apply.sh` detects it via a loose `grep 'include.*noctalia'` against `foot.ini`); `home/nixos/niri.nix` appends `include optional=true "noctalia.kdl"` to the rendered config (niri's builtin `apply.sh` detects it via a basename-anchored regex, resolved relative to niri's own config directory). Both patterns are G3 on-metal-confirmed in both directions: include present → the template writes only its own theme file, never the HM-owned config; include absent + read-only → a clean, non-destructive abort (foot: symlink materialization surfaced only at the next `nh os switch`, not live; niri: atomic `EACCES`, no materialization). GTK takes a different, deliberately-decided path: its `apply.sh` has no pre-declared-include pattern — it detects the HM-owned read-only `gtk.css` symlink and materializes it into a plain file with its own `@import` appended, idempotently on every re-run. This is a per-tool **ownership release**, decided on G3 evidence, not an oversight — `home/nixos/stylix-targets-desktop.nix` drops its own conductor-owned `gtk.extraCss` override in favour of it.
22
+
**3. Mount-points are pre-declared, not runtime-hooked.** `home/nixos/foot.nix` declares `include = "~/.config/foot/themes/noctalia"` (foot's builtin `apply.sh` detects it via a loose `grep 'include.*noctalia'` against `foot.ini`); `home/nixos/niri.nix` appends `include optional=true "noctalia.kdl"` to the rendered config (niri's builtin `apply.sh` detects it via a basename-anchored regex, resolved relative to niri's own config directory). Both patterns are G3 on-metal-confirmed in both directions: include present → the template writes only its own theme file, never the HM-owned config; include absent + read-only → a clean, non-destructive abort (foot: symlink materialization surfaced only at the next `nh os switch`, not live; niri: atomic `EACCES`, no materialization). GTK takes a different, deliberately-decided path: its `apply.sh` has no pre-declared-include pattern, so the file is handed over outright — `home/nixos/stylix-targets-desktop.nix` disables the Stylix `gtk` target's `gtk.css` write (`xdg.configFile."gtk-{3,4}.0/gtk.css".enable = false`, the target staying enabled for settings.ini/adw-gtk3/font), and Noctalia's `apply.sh` creates and owns the file alone. This is a per-tool **ownership release**, decided on G3 evidence, not an oversight — `home/nixos/stylix-targets-desktop.nix` drops its own conductor-owned `gtk.extraCss` override in favour of it. *(As accepted, this item described a partial release in which HM kept declaring `gtk.css` and Noctalia materialized over the read-only symlink; that arrangement was corrected at #874 — see §History.)*
23
23
24
24
**4. The terminal repaint hook.** `home/nixos/noctalia.nix` binds `hooks.colors_changed` (fires strictly after every template finishes writing, source-guaranteed by the single template worker) to a Nix-shipped, `writeShellApplication`-built script. It receives no environment — the hook command runs via `/bin/sh -lc` with no variables passed — so it reads Noctalia's own rendered foot theme file itself and re-points the #609-proven per-pty OSC loop (OSC 4×16 + 10/11, `pgrep`→`ps` discovery — an explicit pty walk, never a signal-by-name) at it, then nudges niri explicitly (`niri msg action load-config-file`, since niri's inotify watch misses the write — niri#2658, the same gap the retired conductor's CLI worked around). The hook fires 3-4× per change at the pinned tag (`v5.0.0-beta.8`) plus once on an unrelated reload — the upstream dedup commit (`460d1d4fc`) does not deliver 1:1 in practice — so **idempotency and cheapness are permanent requirements**, not transitional ones, and the hook reports failure honestly to a state-dir log file rather than swallowing a missing theme file or a dead pty (#303 — no unconditional success).
25
25
@@ -45,7 +45,7 @@ The dialogue settled the user-facing model on the operator's terms: theming ties
45
45
- ✓ One writer, structurally: no declared theme key exists to disagree with the sidecar.
46
46
- ✗ Lifecycle re-coupling: theming re-binds to Noctalia's lifecycle — the exact coupling ADR-044 was built to escape after DMS died (ADR-029) and v4→v5 forced a rewrite (#644). A third, conscious bet on v5's momentum.
47
47
- ✗ Reproducibility is gone for colour: a fresh reprovision no longer restores the operator's look; no mechanism guarantees runtime state matches anything declared (accepted, "declared core, mutable skin" extended to colour).
48
-
- ✗ An activation-window hazard, mechanically closed for foot: between `nh os switch` and Noctalia's next theme resolve the template outputs do not yet exist; foot's fatal missing-include (exit 230) is closed by a seed-if-absent activation placeholder (`noctaliaFootThemeSeed`, operator ruling at #824 — never touches a live theme file), and the remaining GTK window is cosmetic only (Stylix's static base16 until the first resolve). See `docs/desktop/noctalia.md` §Sharp edges.
48
+
- ✗ An activation-window hazard, mechanically closed for foot: between `nh os switch` and Noctalia's next theme resolve the template outputs do not yet exist; foot's fatal missing-include (exit 230) is closed by a seed-if-absent activation placeholder (`noctaliaFootThemeSeed`, operator ruling at #824 — never touches a live theme file), and the remaining GTK window is cosmetic only (adw-gtk3's own defaults, under the Stylix-set theme and font, until the first resolve). See `docs/desktop/noctalia.md` §Sharp edges.*(As accepted, this bullet named Stylix's static base16 as the interim base; that base is no longer written — see §History.)*
49
49
- ✗ Beta authority: the engine is beta with active config-migration churn and thin tests; the per-bump ritual (hook surface, template inventory, sidecar semantics, polarity writer) grows accordingly — unchanged in kind from ADR-044's own beta-authority cost, but now covering a wider surface (templates, not just the engine).
50
50
- ⚠ Migration trigger: if Noctalia's native `color-scheme` write (`b664d3c`, absent from every released tag as of beta.8) ships in a future release, the gtk template's `apply.sh` polarity write becomes redundant rather than wrong — no action required, but worth confirming at the next bump.
51
51
- ⚠ Migration trigger: if a future community template clears its own G3-shaped acceptance gates, `enable_community_templates` and its `community_ids` whitelist get the same explicit, per-id treatment item 2 gave the builtins — never a blanket flip.
@@ -73,3 +73,11 @@ The dialogue settled the user-facing model on the operator's terms: theming ties
73
73
## History
74
74
75
75
**2026-08-13/14** — Drafted alongside the G5 cutover (#824) once G2–G4's evidence was in hand, per the design note's own "ADR timing" rule (freezing earlier would be dishonest against the epic's abort criterion). Status transitions from `Proposed` to `Accepted` at the operator-witnessed G5 pass (#819 DoD items 1–5, on-metal).
76
+
77
+
**2026-08-17 (#874)** — **The GTK ownership release is completed; the shared-ownership arrangement it originally described is withdrawn.** As accepted, decision item 3 left the Stylix `gtk` target declaring `~/.config/gtk-{3,4}.0/gtk.css` as an HM-owned read-only symlink, which Noctalia's `apply.sh` then materialized into a plain file with its own `@import` appended. That arrangement was not stable across rebuilds: the materialized file always differs from HM's source, so each `nh os switch` moved it to `gtk.css.hm-bak`, and the switch after that hit home-manager's refusal to clobber an existing backup. Because `checkNewGenCollision` gates the whole activation script, the failure wedged *all* of the user's home activation — `linkGeneration`, dconf, systemd user-unit reloads, the repo's own activation scripts and the current-generation GC root — not just GTK theming.
78
+
79
+
The fix disables the target's `gtk.css` write outright (`xdg.configFile."gtk-{3,4}.0/gtk.css".enable = false`) rather than forcing HM to win the collision. Noctalia's `ensure_gtk_css_import` creates `gtk.css` from nothing when absent, so nothing depended on HM placing the file first; the target stays enabled for settings.ini (adw-gtk3 + font), which is what the G6 audit (#825) retained it for.
80
+
81
+
**The premise correction matters more than the fix.** This ADR accepted the `@define-color` transient base as unavoidable collateral of keeping the `gtk` target — the G3 spike looked for an `extraCss` seam, did not find one, and concluded ownership had to be shared. Home Manager's per-file `enable` toggle was available the whole time, so the base was never forced on us and the shared-ownership step was avoidable. Reproducibility for colour is unchanged (it was already surrendered by this ADR); what changes is that Nix now declares nothing it does not own.
82
+
83
+
**Recorded departure from the immutable-body convention, operator-authorised.**`docs/decisions/README.md` §Conventions holds that an implemented ADR's body is historical record and corrections belong in §History alone — the rule #872 applied to ADR-018 without touching a character of body prose. Here §Decision item 3 and the activation-window §Consequences bullet were instead amended in place, each carrying a parenthetical marking what the as-accepted text said, on the operator's explicit authorisation in the #874 session. Noted so a later reconciliation sweep reads this as a deliberate exception rather than the drift it is otherwise shaped exactly like. What G5 (#824) actually witnessed on 2026-08-15 was the shared-ownership arrangement described by those parentheticals, not the arrangement the amended prose now states.
Copy file name to clipboardExpand all lines: docs/desktop/noctalia.md
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -27,11 +27,11 @@ Declared baseline, in brief: setup wizard off (carried forward as a taste non-de
27
27
**What Nix still declares is mechanism, not taste** — the residue Noctalia doesn't handle natively:
28
28
29
29
-**A whitelisted builtin-template set, exactly four ids:**`theme.templates.enable_builtin_templates = true` with `builtin_ids = [ "foot" "gtk3" "gtk4" "niri" ]`. `starship` and `btop` are deliberately excluded — the G3 spike's starship incident found both tools' builtin `apply.sh` run `sed -i` against the discovered config path with no read-only awareness, materializing a plain file over an HM-owned symlink (a real, disclosed sandbox safety incident against the operator's live `STARSHIP_CONFIG` — design note §De-risk). `helix` and the remaining community ids are deferred post-cutover. `enable_community_templates` stays `false` — a separate, unreviewed trust surface (arbitrary `apply.sh` via `/bin/sh -lc`, no sandboxing); community **palettes** need no enablement at all and are pickable today, regardless.
30
-
-**Pre-declared mount-points**, not runtime hooks: `home/nixos/foot.nix` declares `include = "~/.config/foot/themes/noctalia"` (foot's builtin `apply.sh` detects it via a loose `grep 'include.*noctalia'`); `home/nixos/niri.nix` appends `include optional=true "noctalia.kdl"` (niri's builtin `apply.sh` detects it via a basename-anchored regex, resolved against niri's own config directory). Both are G3 on-metal-confirmed in both directions — include present → the template writes only its own file, never the HM-owned config; include absent + read-only → a clean, non-destructive abort. GTK takes a different, deliberately-chosen path instead: its `apply.sh` detects the HM-owned read-only `gtk.css` symlink (from Stylix's `gtk` target) and materializes it into a plain file with its own `@import` appended, idempotently on every re-run — a per-tool **ownership release**, decided on G3 evidence (`home/nixos/stylix-targets-desktop.nix` no longer declares a competing `gtk.extraCss`).
30
+
- **Pre-declared mount-points**, not runtime hooks: `home/nixos/foot.nix` declares `include = "~/.config/foot/themes/noctalia"` (foot's builtin `apply.sh` detects it via a loose `grep 'include.*noctalia'`); `home/nixos/niri.nix` appends `include optional=true "noctalia.kdl"` (niri's builtin `apply.sh` detects it via a basename-anchored regex, resolved against niri's own config directory). Both are G3 on-metal-confirmed in both directions — include present → the template writes only its own file, never the HM-owned config; include absent + read-only → a clean, non-destructive abort. GTK takes a different, deliberately-chosen path instead — the file is handed over outright: `home/nixos/stylix-targets-desktop.nix` disables the Stylix `gtk` target's `gtk.css` write (`xdg.configFile."gtk-{3,4}.0/gtk.css".enable = false`, the target staying enabled for settings.ini/adw-gtk3/font), and Noctalia's `apply.sh` creates and owns the file alone. A per-tool **ownership release**, decided on G3 evidence and completed at #874, when the original shared arrangement — HM declaring the symlink, Noctalia materializing over it — turned out to wedge activation every second `nh os switch` (ADR-048 §History).
31
31
-**The `colors_changed` repaint hook** (`home/nixos/noctalia.nix`): fires strictly after every template finishes writing (source-guaranteed by the single template worker) and is executed via `/bin/sh -lc` with **no environment variables** — the script reads Noctalia's own rendered foot theme file itself and re-points the #609-proven per-pty OSC loop (OSC 4×16 + 10/11, `pgrep`→`ps` discovery — never a signal by process name) at it, then nudges niri explicitly (`niri msg action load-config-file`, since niri's inotify watch misses the write — niri#2658). The hook fires 3-4× per change at beta.8 plus once on an unrelated reload (the upstream dedup, `460d1d4fc`, doesn't land 1:1 in practice), so **idempotency and cheapness are permanent requirements**, and it logs failure honestly to `$XDG_STATE_HOME/noctalia-repaint/repaint.log` rather than swallowing a missing theme file or a dead pty (#303).
32
32
-**Polarity** stays off dconf as *our* authority — Noctalia's own gtk template's `apply.sh` is now the sole polarity writer (gsettings, with a dconf fallback), which is what lets Firefox/libadwaita/portal-following apps follow the pick. `b664d3c` (a native Noctalia `color-scheme` write) is absent from every released tag as of beta.8 — a watch item for the next bump, not a released fact.
33
33
34
-
**The activation-window hazard — foot half closed by mechanism, GTK half cosmetic.** Between `nh os switch` completing and Noctalia's next theme resolve, the template outputs do not yet exist/apply. foot treats a missing include as a fatal config error (**exit 230**), so the `noctaliaFootThemeSeed` activation snippet (`home/nixos/noctalia.nix`) seeds an empty, writable placeholder if-absent — new foot windows always launch, wearing default colours until the first resolve overwrites the file (operator ruling, #824; the earlier witnessed-procedure-only stance is superseded). The GTK half remains cosmetic-only: apps opened before the first resolve render Stylix's static base16 base until Noctalia's `apply.sh` materializes `gtk.css` — a theme resolve (control-centre re-apply or shell restart) clears it; no mechanism needed.
34
+
**The activation-window hazard — foot half closed by mechanism, GTK half cosmetic.** Between `nh os switch` completing and Noctalia's next theme resolve, the template outputs do not yet exist/apply. foot treats a missing include as a fatal config error (**exit 230**), so the `noctaliaFootThemeSeed` activation snippet (`home/nixos/noctalia.nix`) seeds an empty, writable placeholder if-absent — new foot windows always launch, wearing default colours until the first resolve overwrites the file (operator ruling, #824; the earlier witnessed-procedure-only stance is superseded). The GTK half remains cosmetic-only: `gtk.css` simply does not exist until Noctalia's `apply.sh` creates it, so apps opened before the first resolve render adw-gtk3's own defaults under the Stylix-set theme and font — a theme resolve (control-centre re-apply or shell restart) clears it; no mechanism needed. This window is now reached only on a host that has never resolved a theme: once the file exists, nothing in Nix touches it again, so a rebuild no longer resets it (#874).
35
35
36
36
## Idle, sleep, caffeine
37
37
@@ -48,7 +48,7 @@ Suspend is guarded, not bare — `[idle.behavior.suspend]` runs `noctalia-idle-g
48
48
## Sharp edges
49
49
50
50
-**Beta seams, re-verified per bump.** Template schema/behaviour, the hook surface (`colors_changed` firing count and ordering), and who writes the polarity signal are facts of the pinned tag, not upstream commitments. The per-bump ritual (caffeine probe pair + a template/hook seam re-check + a `nix build` of the package against the pin) is part of any bump, and C0 re-runs on nixpkgs moves too.
51
-
-**The activation-window hazard** (new foot windows / stale GTK colours between `nh os switch` and Noctalia's next theme resolve) is a witnessed-procedure item, not mechanically closed — see §Theming above.
51
+
-**The activation-window hazard** (new foot windows between `nh os switch` and Noctalia's next theme resolve) is a witnessed-procedure item, not mechanically closed — see §Theming above. Its GTK half no longer recurs per-rebuild: since #874 Nix declares no `gtk.css` at all, so only a host that has never resolved a theme sees the unstyled window.
52
52
-**The polarity signal's writer is Noctalia's own gtk template now — re-check on every bump.** At beta.8 nothing in Noctalia writes `org.gnome.desktop.interface color-scheme` natively; the enabled gtk template's `apply.sh` is the sole writer (gsettings, dconf fallback). Upstream `main` has since made the shell write it natively on every palette resolve, templates or not (`b664d3c`) — see `docs/research/noctalia-v5-native-theming.md` §4. Not in any released tag as of beta.8; when it lands, the gtk-template write simply becomes redundant, not wrong.
53
53
-**`action` strings in `[idle.behavior]` are not schema-validated** — a typo silently becomes command mode; `validateConfig` will not catch it.
54
54
-**Notifications**: v5 claims `org.freedesktop.Notifications` by default and throws if another daemon owns the name (fnott is long decommissioned; nothing competes).
0 commit comments