Skip to content

Commit 6d8a0a7

Browse files
dannyfarisclaude
andauthored
desktop: complete the GTK ownership release — Noctalia owns gtk.css (#874) (#875)
`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>
1 parent 4919b2e commit 6d8a0a7

5 files changed

Lines changed: 29 additions & 21 deletions

File tree

docs/decisions/ADR-048-noctalia-theming-delegation.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The dialogue settled the user-facing model on the operator's terms: theming ties
1919

2020
**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.
2121

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.)*
2323

2424
**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).
2525

@@ -45,7 +45,7 @@ The dialogue settled the user-facing model on the operator's terms: theming ties
4545
- ✓ One writer, structurally: no declared theme key exists to disagree with the sidecar.
4646
- ✗ 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.
4747
- ✗ 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.)*
4949
- ✗ 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).
5050
- ⚠ 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.
5151
- ⚠ 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
7373
## History
7474

7575
**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.

docs/desktop/noctalia.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ Declared baseline, in brief: setup wizard off (carried forward as a taste non-de
2727
**What Nix still declares is mechanism, not taste** — the residue Noctalia doesn't handle natively:
2828

2929
- **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).
3131
- **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).
3232
- **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.
3333

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).
3535

3636
## Idle, sleep, caffeine
3737

@@ -48,7 +48,7 @@ Suspend is guarded, not bare — `[idle.behavior.suspend]` runs `noctalia-idle-g
4848
## Sharp edges
4949

5050
- **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.
5252
- **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.
5353
- **`action` strings in `[idle.behavior]` are not schema-validated** — a typo silently becomes command mode; `validateConfig` will not catch it.
5454
- **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

Comments
 (0)