diff --git a/.gitignore b/.gitignore
index 0e83a5e..54617e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
nohup.out
mako/config
+__pycache__/
diff --git a/docs/superpowers/plans/2026-06-07-theme-centralization-and-bluetooth.md b/docs/superpowers/plans/2026-06-07-theme-centralization-and-bluetooth.md
new file mode 100644
index 0000000..c6ddfa9
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-07-theme-centralization-and-bluetooth.md
@@ -0,0 +1,549 @@
+# Theme Centralization & Bluetooth Module Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `theme/colors.json` the single source of truth for the desktop palette, generating all derived configs (waybar/wofi CSS, mako, hyprlock colors) from it at install time, and add a bluetooth indicator to waybar.
+
+**Architecture:** A pure-function Python generator (`theme/generate-theme.py`) reads `colors.json` and renders three artifacts into `~/.config` at install time (matching how mako is already generated). `hyprlock.conf` switches to a sourced hyprlang color file using `$tn_*` variables. The hand-maintained `theme/colors.css` is deleted and becomes a generated artifact. Bluetooth uses waybar's built-in module.
+
+**Tech Stack:** Python 3 (stdlib only: `json`, `os`), waybar (JSONC + GTK CSS), hyprlang (hyprlock config), bash (install.sh).
+
+---
+
+## File Structure
+
+- `theme/colors.json` — **source of truth** (unchanged; 12 base colors).
+- `theme/generate-theme.py` — **new** generator. Pure render functions + a `main()` that writes outputs. One responsibility: turn the palette into config artifacts.
+- `theme/test_generate_theme.py` — **new** test for the generator's pure functions.
+- `theme/colors.css` — **deleted from repo** (now generated into `~/.config/theme/colors.css`).
+- `install.sh` — **modified**: call the generator, drop the inline mako snippet and the colors.css symlink.
+- `hyprlock/hyprlock.conf` — **modified**: `source=` the generated color file, replace hardcoded `rgba(...)` with `$tn_*` vars.
+- `waybar/config.jsonc` — **modified**: add `bluetooth` module.
+- `waybar/style.css` — **modified**: add `#bluetooth` style rules.
+- `packages.txt` — **modified**: ensure `bluez`/`bluez-utils` present.
+
+Two independent features (theme: Tasks 1–6; bluetooth: Task 7). Implement and verify separately.
+
+---
+
+## Task 1: Generator — palette loading and hex conversion
+
+**Files:**
+- Create: `theme/generate-theme.py`
+- Test: `theme/test_generate_theme.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# theme/test_generate_theme.py
+import importlib.util, os
+
+_spec = importlib.util.spec_from_file_location(
+ "gen", os.path.join(os.path.dirname(__file__), "generate-theme.py"))
+gen = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(gen)
+
+
+def test_load_palette_reads_colors_json():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ assert pal["background"] == "#1d1f21"
+ assert pal["blue"] == "#81a2be"
+
+
+def test_hex_to_hyprlock_rgba():
+ assert gen.hex_to_hyprlock_rgba("#81a2be") == "rgba(81a2beff)"
+ assert gen.hex_to_hyprlock_rgba("#1d1f21") == "rgba(1d1f21ff)"
+
+
+def test_hex_to_rgb_tuple():
+ assert gen.hex_to_rgb("#1d1f21") == (29, 31, 33)
+
+
+if __name__ == "__main__":
+ test_load_palette_reads_colors_json()
+ test_hex_to_hyprlock_rgba()
+ test_hex_to_rgb_tuple()
+ print("OK")
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: FAIL — `FileNotFoundError` / `AttributeError: module 'gen' has no attribute 'load_palette'` (generate-theme.py does not exist yet).
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# theme/generate-theme.py
+"""Generate desktop config artifacts from theme/colors.json (single source of truth)."""
+import json
+import os
+
+
+def load_palette(path):
+ return json.load(open(path))
+
+
+def hex_to_rgb(h):
+ h = h.lstrip("#")
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
+
+
+def hex_to_hyprlock_rgba(h):
+ return f"rgba({h.lstrip('#')}ff)"
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: `OK`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add theme/generate-theme.py theme/test_generate_theme.py
+git commit -m "feat(theme): generator palette loading + hex conversion"
+```
+
+---
+
+## Task 2: Generator — render colors.css (with alpha variants)
+
+**Files:**
+- Modify: `theme/generate-theme.py`
+- Test: `theme/test_generate_theme.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `theme/test_generate_theme.py` (and add the call in `__main__`):
+
+```python
+def test_render_colors_css():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ css = gen.render_colors_css(pal)
+ # base colors
+ assert "@define-color tn-bg #1d1f21;" in css
+ assert "@define-color tn-bg-alt #282a2e;" in css
+ assert "@define-color tn-bg-high #373b41;" in css
+ assert "@define-color tn-fg #c5c8c6;" in css
+ assert "@define-color tn-fg-dim #969896;" in css
+ assert "@define-color tn-fg-muted #707880;" in css
+ assert "@define-color tn-blue #81a2be;" in css
+ assert "@define-color tn-green #b5bd68;" in css
+ assert "@define-color tn-yellow #f0c674;" in css
+ assert "@define-color tn-red #cc6666;" in css
+ assert "@define-color tn-magenta #b294bb;" in css
+ assert "@define-color tn-cyan #8abeb7;" in css
+ # alpha variants computed from background (29,31,33)
+ assert "@define-color tn-bg-a90 rgba(29, 31, 33, 0.90);" in css
+ assert "@define-color tn-bg-a95 rgba(29, 31, 33, 0.95);" in css
+ assert "@define-color tn-bg-a96 rgba(29, 31, 33, 0.96);" in css
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: FAIL — `AttributeError: module 'gen' has no attribute 'render_colors_css'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `theme/generate-theme.py`:
+
+```python
+# (key in colors.json) -> (css variable suffix)
+_CSS_MAP = [
+ ("background", "bg"), ("background_alt", "bg-alt"), ("background_high", "bg-high"),
+ ("foreground", "fg"), ("foreground_dim", "fg-dim"), ("foreground_muted", "fg-muted"),
+ ("blue", "blue"), ("green", "green"), ("yellow", "yellow"),
+ ("red", "red"), ("magenta", "magenta"), ("cyan", "cyan"),
+]
+_ALPHA_VARIANTS = [("a90", "0.90"), ("a95", "0.95"), ("a96", "0.96")]
+
+
+def render_colors_css(pal):
+ lines = ["/* Tomorrow Night - GENERATED from colors.json by generate-theme.py. DO NOT EDIT. */", ""]
+ for key, suffix in _CSS_MAP:
+ lines.append(f"@define-color tn-{suffix} {pal[key]};")
+ lines.append("")
+ r, g, b = hex_to_rgb(pal["background"])
+ for name, alpha in _ALPHA_VARIANTS:
+ lines.append(f"@define-color tn-bg-{name} rgba({r}, {g}, {b}, {alpha});")
+ return "\n".join(lines) + "\n"
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: `OK`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add theme/generate-theme.py theme/test_generate_theme.py
+git commit -m "feat(theme): render colors.css from palette"
+```
+
+---
+
+## Task 3: Generator — render mako config
+
+**Files:**
+- Modify: `theme/generate-theme.py`
+- Test: `theme/test_generate_theme.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `theme/test_generate_theme.py` (and call in `__main__`):
+
+```python
+def test_render_mako_config():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ cfg = gen.render_mako_config(pal)
+ assert "background-color=#1d1f21" in cfg
+ assert "text-color=#c5c8c6" in cfg
+ assert "border-size=2" in cfg
+ assert "border-color=#81a2be" in cfg
+ assert "default-timeout=4000" in cfg
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: FAIL — `AttributeError: module 'gen' has no attribute 'render_mako_config'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `theme/generate-theme.py` (preserves the exact output of the current inline install.sh snippet):
+
+```python
+def render_mako_config(pal):
+ return (
+ f"background-color={pal['background']}\n"
+ f"text-color={pal['foreground']}\n"
+ "border-size=2\n"
+ f"border-color={pal['blue']}\n"
+ "default-timeout=4000\n"
+ )
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: `OK`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add theme/generate-theme.py theme/test_generate_theme.py
+git commit -m "feat(theme): render mako config from palette"
+```
+
+---
+
+## Task 4: Generator — render hyprlock colors + `main()` writer
+
+**Files:**
+- Modify: `theme/generate-theme.py`
+- Test: `theme/test_generate_theme.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `theme/test_generate_theme.py` (and call in `__main__`):
+
+```python
+def test_render_hyprlock_colors():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ out = gen.render_hyprlock_colors(pal)
+ assert "$tn_fg = rgba(c5c8c6ff)" in out
+ assert "$tn_fg_dim = rgba(969896ff)" in out
+ assert "$tn_bg_alt = rgba(282a2eff)" in out
+ assert "$tn_blue = rgba(81a2beff)" in out
+ assert "$tn_red = rgba(cc6666ff)" in out
+ assert "$tn_green = rgba(b5bd68ff)" in out
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: FAIL — `AttributeError: module 'gen' has no attribute 'render_hyprlock_colors'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `theme/generate-theme.py`. The `_HYPRLOCK_MAP` covers exactly the colors hyprlock.conf needs:
+
+```python
+# (colors.json key) -> (hyprlock variable name)
+_HYPRLOCK_MAP = [
+ ("foreground", "tn_fg"), ("foreground_dim", "tn_fg_dim"),
+ ("background_alt", "tn_bg_alt"), ("blue", "tn_blue"),
+ ("red", "tn_red"), ("green", "tn_green"),
+]
+
+
+def render_hyprlock_colors(pal):
+ lines = ["# GENERATED from colors.json by generate-theme.py. DO NOT EDIT."]
+ for key, var in _HYPRLOCK_MAP:
+ lines.append(f"${var} = {hex_to_hyprlock_rgba(pal[key])}")
+ return "\n".join(lines) + "\n"
+
+
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ pal = load_palette(os.path.join(here, "colors.json"))
+ cfgdir = os.path.expanduser("~/.config")
+ os.makedirs(os.path.join(cfgdir, "theme"), exist_ok=True)
+ os.makedirs(os.path.join(cfgdir, "mako"), exist_ok=True)
+ writes = {
+ os.path.join(cfgdir, "theme", "colors.css"): render_colors_css(pal),
+ os.path.join(cfgdir, "mako", "config"): render_mako_config(pal),
+ os.path.join(cfgdir, "theme", "hyprlock-colors.conf"): render_hyprlock_colors(pal),
+ }
+ for path, content in writes.items():
+ with open(path, "w") as f:
+ f.write(content)
+ print(f" generated {path}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py`
+Expected: `OK`
+
+- [ ] **Step 5: Verify the writer produces a colors.css matching the current one**
+
+Run:
+```bash
+cd ~/Documents/dotfiles && python3 theme/generate-theme.py
+diff <(grep '@define-color' theme/colors.css) <(grep '@define-color' ~/.config/theme/colors.css)
+```
+Expected: no differences (the generated palette matches the committed hand-written one). If `~/.config/theme/colors.css` was a symlink to the repo file, `diff` shows nothing because they are the same file — that is fine; the symlink is removed in Task 6.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add theme/generate-theme.py theme/test_generate_theme.py
+git commit -m "feat(theme): render hyprlock colors + main() writer"
+```
+
+---
+
+## Task 5: Convert hyprlock.conf to the shared palette
+
+**Files:**
+- Modify: `hyprlock/hyprlock.conf` (add `source=` line; replace 7 `rgba(...)` at lines 27, 38, 50–54)
+
+- [ ] **Step 1: Add the source line at the top of `hyprlock/hyprlock.conf`**
+
+Insert as the very first line (before `general {`):
+
+```
+source = ~/.config/theme/hyprlock-colors.conf
+
+```
+
+- [ ] **Step 2: Replace the hardcoded colors with variables**
+
+Apply these exact replacements in `hyprlock/hyprlock.conf`:
+
+| Line context | From | To |
+|---|---|---|
+| label (clock) | `color = rgba(c5c8c6ff)` | `color = $tn_fg` |
+| label (date) | `color = rgba(969896ff)` | `color = $tn_fg_dim` |
+| input-field | `inner_color = rgba(282a2eff)` | `inner_color = $tn_bg_alt` |
+| input-field | `outer_color = rgba(81a2beff)` | `outer_color = $tn_blue` |
+| input-field | `font_color = rgba(c5c8c6ff)` | `font_color = $tn_fg` |
+| input-field | `fail_color = rgba(cc6666ff)` | `fail_color = $tn_red` |
+| input-field | `check_color = rgba(b5bd68ff)` | `check_color = $tn_green` |
+
+- [ ] **Step 3: Generate the hyprlock color file and parse-test hyprlock**
+
+Run:
+```bash
+cd ~/Documents/dotfiles && python3 theme/generate-theme.py
+cat ~/.config/theme/hyprlock-colors.conf
+# Parse test: launch with no grace, watch for parse errors, kill after 2s before interacting.
+timeout 2 hyprlock --verbose 2>&1 | grep -iE 'error|invalid|fail to parse|unknown' || echo "NO PARSE ERRORS"
+```
+Expected: the color file lists the 6 `$tn_*` variables; the parse test prints `NO PARSE ERRORS`. (hyprlock will lock the screen for ~2s — unlock with your password/fingerprint after `timeout` kills it. Do this when you can attend the screen.)
+
+- [ ] **Step 4: FALLBACK (only if Step 3 shows parse errors for `source=` or `$variables`)**
+
+If hyprlock v0.9.5 rejects `source=`/variables, abandon the source approach for hyprlock:
+1. Revert `hyprlock/hyprlock.conf` changes: `git checkout hyprlock/hyprlock.conf`
+2. Rename it to a template: `git mv hyprlock/hyprlock.conf hyprlock/hyprlock.conf.tmpl`
+3. In `hyprlock.conf.tmpl`, replace the 7 `rgba(...)` values with `__TN_FG__`, `__TN_FG_DIM__`, `__TN_BG_ALT__`, `__TN_BLUE__`, `__TN_RED__`, `__TN_GREEN__` placeholders.
+4. Add a `render_hyprlock_conf(pal, template_str)` function to the generator that `.replace()`s each placeholder with `hex_to_hyprlock_rgba(pal[key])`, plus a test asserting no `__` placeholders remain and `rgba(c5c8c6ff)` is present.
+5. In `main()`, read the template and write the rendered file to `~/.config/hypr/hyprlock.conf`.
+6. In `install.sh` (Task 6), remove the `ln -sf .../hyprlock/hyprlock.conf` symlink (now generated).
+Document in the commit message which path was taken.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add hyprlock/hyprlock.conf
+git commit -m "feat(theme): hyprlock uses shared palette via sourced variables"
+```
+
+---
+
+## Task 6: Wire generator into install.sh; remove colors.css from repo
+
+**Files:**
+- Modify: `install.sh` (replace lines 32–42 inline python; remove line 29 colors.css symlink)
+- Delete: `theme/colors.css`
+
+- [ ] **Step 1: Remove the inline mako python block and the colors.css symlink**
+
+In `install.sh`, delete the colors.css symlink line:
+```bash
+ln -sf "$(pwd)/theme/colors.css" ~/.config/theme/colors.css
+```
+and delete the entire inline block:
+```bash
+python3 - <<'PYEOF'
+import json, os
+c = json.load(open("theme/colors.json"))
+config = f"""background-color={c['background']}
+text-color={c['foreground']}
+border-size=2
+border-color={c['blue']}
+default-timeout=4000
+"""
+open(os.path.expanduser("~/.config/mako/config"), "w").write(config)
+PYEOF
+```
+
+- [ ] **Step 2: Add the generator call**
+
+In `install.sh`, after the `mkdir -p ~/.config/theme` line (and after the wofi/waybar symlinks), add:
+
+```bash
+echo "==> Generating theme artifacts from colors.json"
+python3 "$(pwd)/theme/generate-theme.py"
+```
+
+- [ ] **Step 3: Delete the now-generated colors.css from the repo**
+
+```bash
+cd ~/Documents/dotfiles && git rm theme/colors.css
+# Remove any stale symlink so generation writes a real file:
+rm -f ~/.config/theme/colors.css
+```
+
+- [ ] **Step 4: Run install.sh's theming path and verify all artifacts regenerate**
+
+Run:
+```bash
+cd ~/Documents/dotfiles && python3 theme/generate-theme.py
+test -f ~/.config/theme/colors.css && echo "colors.css OK"
+test -f ~/.config/mako/config && echo "mako OK"
+test -f ~/.config/theme/hyprlock-colors.conf && echo "hyprlock-colors OK"
+grep -q '@define-color tn-blue #81a2be;' ~/.config/theme/colors.css && echo "palette OK"
+waybar-restart
+```
+Expected: all four `OK` lines print, and waybar restarts with colors intact (no GTK CSS parse errors in its output).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add install.sh
+git rm --cached theme/colors.css 2>/dev/null || true
+git commit -m "build(theme): generate all theme artifacts from colors.json in install.sh"
+```
+
+---
+
+## Task 7: Bluetooth waybar module
+
+**Files:**
+- Modify: `waybar/config.jsonc` (add `bluetooth` to `modules-right`; add module block)
+- Modify: `waybar/style.css` (add `#bluetooth` rules)
+- Modify: `packages.txt` (ensure `bluez`, `bluez-utils`)
+
+- [ ] **Step 1: Ensure bluez packages are present**
+
+In `packages.txt`, confirm/add (alongside the existing `blueman`):
+```
+bluez
+bluez-utils
+```
+
+- [ ] **Step 2: Ensure the bluetooth service is running (one-time, host setup)**
+
+Run:
+```bash
+systemctl is-enabled bluetooth.service 2>/dev/null || sudo systemctl enable --now bluetooth.service
+systemctl is-active bluetooth.service
+```
+Expected: `active`. (The waybar module reads adapter state from the bluez daemon.)
+
+- [ ] **Step 3: Add the bluetooth module to waybar config**
+
+In `waybar/config.jsonc`, add `"bluetooth"` to `modules-right` immediately after `"network"`:
+```jsonc
+ "network",
+ "bluetooth",
+ "battery",
+```
+And add this module block (e.g. after the `network` block):
+```jsonc
+ "bluetooth": {
+ "format": " ",
+ "format-disabled": "",
+ "format-off": "",
+ "format-connected": " {num_connections}",
+ "tooltip-format": "{controller_alias}\n{status}",
+ "tooltip-format-connected": "{controller_alias}\n{num_connections} connected\n{device_enumerate}",
+ "tooltip-format-enumerate-connected": "{device_alias}",
+ "on-click": "blueman-manager",
+ },
+```
+
+- [ ] **Step 4: Add bluetooth styling**
+
+In `waybar/style.css`, add `#bluetooth` to the shared padding selector list and add state colors:
+```css
+#bluetooth {
+ padding: 0 12px;
+}
+
+#bluetooth.connected {
+ color: @tn-blue;
+}
+
+#bluetooth.disabled, #bluetooth.off {
+ color: @tn-fg-muted;
+}
+```
+
+- [ ] **Step 5: Verify the module renders**
+
+Run:
+```bash
+waybar-restart
+```
+Expected: a bluetooth glyph appears between network and battery; clicking it opens `blueman-manager`; toggling bluetooth changes the glyph/color. No waybar errors in output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add waybar/config.jsonc waybar/style.css packages.txt
+git commit -m "feat(waybar): add bluetooth module"
+```
+
+---
+
+## Final Verification
+
+- [ ] Run the generator test suite: `cd ~/Documents/dotfiles && python3 theme/test_generate_theme.py` → `OK`
+- [ ] Re-run generation and confirm idempotency: `python3 theme/generate-theme.py` twice → identical files, no errors.
+- [ ] `waybar-restart` → bar renders with correct colors + bluetooth module.
+- [ ] Open wofi (Mod+D) → styling intact.
+- [ ] Trigger a notification → mako uses palette colors.
+- [ ] Lock screen (Mod+Shift+E) → hyprlock colors correct; unlock works.
+- [ ] `grep -rn 'rgba(' hyprlock/hyprlock.conf` → no hardcoded colors remain (only `$tn_*` vars), unless the Task 5 fallback template path was taken.
+- [ ] Confirm `theme/colors.css` no longer exists in the repo: `git ls-files theme/colors.css` → empty.
diff --git a/docs/superpowers/specs/2026-06-07-theme-centralization-and-bluetooth-design.md b/docs/superpowers/specs/2026-06-07-theme-centralization-and-bluetooth-design.md
new file mode 100644
index 0000000..15243db
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-07-theme-centralization-and-bluetooth-design.md
@@ -0,0 +1,141 @@
+# Theme Centralization & Bluetooth Module — Design
+
+Date: 2026-06-07
+Status: Approved (pending spec review)
+
+## Summary
+
+Two independent enhancements to the niri dotfiles:
+
+1. **Theme centralization** — collapse the duplicated color palette into a single
+ source of truth (`theme/colors.json`) and generate all derived configs from it
+ at install time. Bring `hyprlock` under the shared palette. Leave `alacritty`
+ untouched (out of scope).
+2. **Bluetooth module** — add a waybar bluetooth indicator using the
+ already-installed `blueman`/`bluez` stack.
+
+## Motivation
+
+`CLAUDE.md` mandates a single source of truth and warns against duplicating
+configs / hardcoding colors in multiple places. Current state violates this:
+
+- `theme/colors.css` (`@define-color` palette) and `theme/colors.json` (same
+ palette as JSON) **both** hand-maintain the same 12 colors. Changing a color
+ requires editing both files.
+- `hyprlock/hyprlock.conf` hardcodes 7 `rgba(...)` colors, every one of which
+ already exists in the palette.
+
+Already-correct (not changed by this work):
+
+- waybar (`waybar/style.css`) and wofi (`wofi/style.css`) `@import` `colors.css`.
+- mako config is generated from `colors.json` by `install.sh`.
+
+## Scope
+
+**In scope:** unify the two palette files into one source; generate `colors.css`,
+the mako config, and a hyprlock color file from it; convert `hyprlock.conf` to
+reference the shared palette; add a waybar bluetooth module.
+
+**Out of scope:** alacritty terminal colors (needs a full 16-color normal+bright
+set the palette does not currently model — deferred); any change to the
+Tomorrow Night palette values themselves; GTK theming (handled by Materia).
+
+## Approach
+
+Chosen approach **A**: `theme/colors.json` is the single source of truth;
+everything else is generated at install time. This extends the existing Python
+generation block already in `install.sh` (which generates the mako config).
+Rejected: parsing `colors.css` as the source (fragile regex), and introducing a
+new neutral source format (YAGNI for this scope, discards working JSON+Python).
+
+## Detailed Design
+
+### 1. Single source of truth
+
+`theme/colors.json` remains the only hand-edited palette file. It holds the 12
+base colors (already present). The hand-maintained `theme/colors.css` is
+**deleted from the repo**; it becomes a generated artifact.
+
+### 2. Generator: `theme/generate-theme.py`
+
+A new standalone script, invoked by `install.sh`, replacing the current inline
+Python mako snippet. Reads `theme/colors.json` and writes three artifacts
+directly into `~/.config` (written, not symlinked — matching how mako is handled
+today):
+
+- **`~/.config/theme/colors.css`** — `@define-color` block for all 12 base
+ colors, plus the three alpha variants (`tn-bg-a90`, `tn-bg-a95`, `tn-bg-a96`)
+ computed from the `background` color at opacities 0.90 / 0.95 / 0.96. This is
+ what waybar and wofi already `@import`; their files do **not** change.
+- **`~/.config/mako/config`** — same output as the current inline snippet.
+- **`~/.config/theme/hyprlock-colors.conf`** — hyprlang variable declarations,
+ one per needed color, e.g. `$tn_fg = rgba(c5c8c6ff)`. Hex from `colors.json` is
+ converted to hyprlock's `rgba(RRGGBBAA)` form with `AA = ff`.
+
+The generator must be idempotent and safe to re-run (overwrites its outputs).
+
+### 3. hyprlock.conf
+
+- Add `source = ~/.config/theme/hyprlock-colors.conf` near the top.
+- Replace the 7 hardcoded `rgba(...)` values with the matching `$tn_*`
+ variables. Color → variable mapping (from current values):
+ - `rgba(c5c8c6ff)` → `$tn_fg` (foreground)
+ - `rgba(969896ff)` → `$tn_fg_dim` (foreground_dim)
+ - `rgba(282a2eff)` → `$tn_bg_alt` (background_alt)
+ - `rgba(81a2beff)` → `$tn_blue` (blue)
+ - `rgba(cc6666ff)` → `$tn_red` (red)
+ - `rgba(b5bd68ff)` → `$tn_green` (green)
+- `hyprlock.conf` stays a hand-edited, symlinked file; only its colors come from
+ the generated source file.
+
+**Risk + fallback:** hyprlock v0.9.5 uses the hyprlang parser, which documents
+support for `$variables` and `source=`. This is verified by documentation, not
+empirically (no dry-run flag; launching hyprlock grabs the screen). The
+implementation plan MUST include an explicit parse test (lock once and confirm
+colors render, or launch with `--grace 0` and watch `--verbose` for parse
+errors). **Fallback if `source=`/variables misbehave:** generate the entire
+`hyprlock.conf` from a `hyprlock/hyprlock.conf.tmpl` template with color
+placeholders, instead of using `source=`. Same single-source outcome, more
+generation.
+
+### 4. install.sh changes
+
+- Remove the inline `python3 - <<'PYEOF' ... PYEOF` mako block.
+- Add a call to `python3 theme/generate-theme.py` after configs are linked.
+- Remove the `ln -sf .../theme/colors.css ~/.config/theme/colors.css` line
+ (colors.css is now generated into that path, not symlinked).
+- Ensure `~/.config/theme` exists before generation (already created).
+
+### 5. Bluetooth waybar module
+
+- `waybar/config.jsonc`: add `"bluetooth"` to `modules-right` (placed next to
+ `network`), with a `bluetooth` module block:
+ - `format`, `format-connected`, `format-disabled` using Nerd Font glyphs.
+ - `on-click`: `blueman-manager`.
+- `waybar/style.css`: add a `#bluetooth` padding rule (consistent with the other
+ right-side modules) plus a connected-state accent color using a palette
+ variable (e.g. `@tn-blue`).
+- Dependencies: `blueman` + `bluez`/`bluez-utils` (blueman pulls bluez). Confirm
+ `packages.txt` lists what's needed; the waybar bluetooth module requires the
+ `bluez` daemon running.
+
+## Testing / Verification
+
+- **Generator:** run `python3 theme/generate-theme.py`; diff generated
+ `~/.config/theme/colors.css` against the previous hand-written file to confirm
+ identical palette + alpha variants. Confirm mako config output unchanged.
+- **waybar/wofi:** restart waybar (`waybar-restart`), confirm no CSS errors and
+ colors render. Open wofi, confirm styling intact.
+- **hyprlock:** parse test per the fallback note above; confirm lock screen
+ colors match before/after.
+- **Bluetooth:** confirm module appears, reflects adapter state, and
+ `blueman-manager` opens on click.
+- **Idempotency:** re-run `install.sh`; confirm no errors and stable output.
+
+## Rollout Notes
+
+- Generated files (`~/.config/theme/colors.css`, `~/.config/theme/hyprlock-colors.conf`)
+ are not committed and not symlinked — consistent with the existing mako
+ generation. The committed `theme/colors.css` is removed in this change.
+- The two features (theme, bluetooth) are independent and can be implemented and
+ verified separately.
diff --git a/hyprlock/hyprlock.conf b/hyprlock/hyprlock.conf
index 0132396..34e7b0d 100644
--- a/hyprlock/hyprlock.conf
+++ b/hyprlock/hyprlock.conf
@@ -1,3 +1,5 @@
+source = ~/.config/theme/hyprlock-colors.conf
+
general {
grace = 0
hide_cursor = true
@@ -24,7 +26,7 @@ label {
text = cmd[update:1000] date +"%-I:%M %p"
font_family = JetBrains Mono Nerd Font
font_size = 96
- color = rgba(c5c8c6ff)
+ color = $tn_fg
position = 0, 150
halign = center
valign = center
@@ -35,7 +37,7 @@ label {
text = cmd[update:60000] date +"%A, %B %-d"
font_family = JetBrains Mono Nerd Font
font_size = 24
- color = rgba(969896ff)
+ color = $tn_fg_dim
position = 0, 40
halign = center
valign = center
@@ -47,11 +49,11 @@ input-field {
outline_thickness = 2
dots_size = 0.25
dots_spacing = 0.4
- inner_color = rgba(282a2eff)
- outer_color = rgba(81a2beff)
- font_color = rgba(c5c8c6ff)
- fail_color = rgba(cc6666ff)
- check_color = rgba(b5bd68ff)
+ inner_color = $tn_bg_alt
+ outer_color = $tn_blue
+ font_color = $tn_fg
+ fail_color = $tn_red
+ check_color = $tn_green
placeholder_text = Touch fingerprint or type password
fail_text = $FAIL
fade_on_empty = false
diff --git a/install.sh b/install.sh
index d1c0383..c81514d 100755
--- a/install.sh
+++ b/install.sh
@@ -26,20 +26,10 @@ ln -sf "$(pwd)/niri/config.kdl" ~/.config/niri/config.kdl
ln -sf "$(pwd)/waybar/config.jsonc" ~/.config/waybar/config.jsonc
ln -sf "$(pwd)/waybar/style.css" ~/.config/waybar/style.css
mkdir -p ~/.config/theme
-ln -sf "$(pwd)/theme/colors.css" ~/.config/theme/colors.css
+echo "==> Generating theme artifacts from colors.json"
+python3 "$(pwd)/theme/generate-theme.py"
ln -sf "$(pwd)/wofi/config" ~/.config/wofi/config
ln -sf "$(pwd)/wofi/style.css" ~/.config/wofi/style.css
-python3 - <<'PYEOF'
-import json, os
-c = json.load(open("theme/colors.json"))
-config = f"""background-color={c['background']}
-text-color={c['foreground']}
-border-size=2
-border-color={c['blue']}
-default-timeout=4000
-"""
-open(os.path.expanduser("~/.config/mako/config"), "w").write(config)
-PYEOF
mkdir -p ~/.config/hypr
ln -sf "$(pwd)/hyprlock/hyprlock.conf" ~/.config/hypr/hyprlock.conf
ln -sf "$(pwd)/fish/config.fish" ~/.config/fish/config.fish
diff --git a/packages.txt b/packages.txt
index 23702b5..cac1d80 100644
--- a/packages.txt
+++ b/packages.txt
@@ -24,6 +24,8 @@ greetd-regreet
cage
cliphist
blueman
+bluez
+bluez-utils
wlsunset
pavucontrol
nwg-look
diff --git a/theme/colors.css b/theme/colors.css
deleted file mode 100644
index c7bfe56..0000000
--- a/theme/colors.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/* Tomorrow Night - shared color palette */
-/* @import this file from waybar, wofi CSS */
-
-@define-color tn-bg #1d1f21;
-@define-color tn-bg-alt #282a2e;
-@define-color tn-bg-high #373b41;
-@define-color tn-fg #c5c8c6;
-@define-color tn-fg-dim #969896;
-@define-color tn-fg-muted #707880;
-@define-color tn-blue #81a2be;
-@define-color tn-green #b5bd68;
-@define-color tn-yellow #f0c674;
-@define-color tn-red #cc6666;
-@define-color tn-magenta #b294bb;
-@define-color tn-cyan #8abeb7;
-
-/* Alpha variants */
-@define-color tn-bg-a90 rgba(29, 31, 33, 0.90);
-@define-color tn-bg-a95 rgba(29, 31, 33, 0.95);
-@define-color tn-bg-a96 rgba(29, 31, 33, 0.96);
diff --git a/theme/generate-theme.py b/theme/generate-theme.py
new file mode 100644
index 0000000..e924b33
--- /dev/null
+++ b/theme/generate-theme.py
@@ -0,0 +1,85 @@
+# theme/generate-theme.py
+"""Generate desktop config artifacts from theme/colors.json (single source of truth)."""
+import json
+import os
+
+
+def load_palette(path):
+ with open(path) as f:
+ return json.load(f)
+
+
+def hex_to_rgb(h):
+ h = h.lstrip("#")
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
+
+
+def hex_to_hyprlock_rgba(h):
+ return f"rgba({h.lstrip('#')}ff)"
+
+
+# (key in colors.json) -> (css variable suffix)
+_CSS_MAP = [
+ ("background", "bg"), ("background_alt", "bg-alt"), ("background_high", "bg-high"),
+ ("foreground", "fg"), ("foreground_dim", "fg-dim"), ("foreground_muted", "fg-muted"),
+ ("blue", "blue"), ("green", "green"), ("yellow", "yellow"),
+ ("red", "red"), ("magenta", "magenta"), ("cyan", "cyan"),
+]
+_ALPHA_VARIANTS = [("a90", "0.90"), ("a95", "0.95"), ("a96", "0.96")]
+
+
+def render_mako_config(pal):
+ return (
+ f"background-color={pal['background']}\n"
+ f"text-color={pal['foreground']}\n"
+ "border-size=2\n"
+ f"border-color={pal['blue']}\n"
+ "default-timeout=4000\n"
+ )
+
+
+def render_colors_css(pal):
+ lines = ["/* Tomorrow Night - GENERATED from colors.json by generate-theme.py. DO NOT EDIT. */", ""]
+ for key, suffix in _CSS_MAP:
+ lines.append(f"@define-color tn-{suffix} {pal[key]};")
+ lines.append("")
+ r, g, b = hex_to_rgb(pal["background"])
+ for name, alpha in _ALPHA_VARIANTS:
+ lines.append(f"@define-color tn-bg-{name} rgba({r}, {g}, {b}, {alpha});")
+ return "\n".join(lines) + "\n"
+
+
+# (colors.json key) -> (hyprlock variable name)
+_HYPRLOCK_MAP = [
+ ("foreground", "tn_fg"), ("foreground_dim", "tn_fg_dim"),
+ ("background_alt", "tn_bg_alt"), ("blue", "tn_blue"),
+ ("red", "tn_red"), ("green", "tn_green"),
+]
+
+
+def render_hyprlock_colors(pal):
+ lines = ["# GENERATED from colors.json by generate-theme.py. DO NOT EDIT."]
+ for key, var in _HYPRLOCK_MAP:
+ lines.append(f"${var} = {hex_to_hyprlock_rgba(pal[key])}")
+ return "\n".join(lines) + "\n"
+
+
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ pal = load_palette(os.path.join(here, "colors.json"))
+ cfgdir = os.path.expanduser("~/.config")
+ os.makedirs(os.path.join(cfgdir, "theme"), exist_ok=True)
+ os.makedirs(os.path.join(cfgdir, "mako"), exist_ok=True)
+ writes = {
+ os.path.join(cfgdir, "theme", "colors.css"): render_colors_css(pal),
+ os.path.join(cfgdir, "mako", "config"): render_mako_config(pal),
+ os.path.join(cfgdir, "theme", "hyprlock-colors.conf"): render_hyprlock_colors(pal),
+ }
+ for path, content in writes.items():
+ with open(path, "w") as f:
+ f.write(content)
+ print(f" generated {path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/theme/test_generate_theme.py b/theme/test_generate_theme.py
new file mode 100644
index 0000000..dad68e9
--- /dev/null
+++ b/theme/test_generate_theme.py
@@ -0,0 +1,75 @@
+# theme/test_generate_theme.py
+import importlib.util, os
+
+_spec = importlib.util.spec_from_file_location(
+ "gen", os.path.join(os.path.dirname(__file__), "generate-theme.py"))
+gen = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(gen)
+
+
+def test_load_palette_reads_colors_json():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ assert pal["background"] == "#1d1f21"
+ assert pal["blue"] == "#81a2be"
+
+
+def test_hex_to_hyprlock_rgba():
+ assert gen.hex_to_hyprlock_rgba("#81a2be") == "rgba(81a2beff)"
+ assert gen.hex_to_hyprlock_rgba("#1d1f21") == "rgba(1d1f21ff)"
+
+
+def test_hex_to_rgb_tuple():
+ assert gen.hex_to_rgb("#1d1f21") == (29, 31, 33)
+
+
+def test_render_colors_css():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ css = gen.render_colors_css(pal)
+ # base colors
+ assert "@define-color tn-bg #1d1f21;" in css
+ assert "@define-color tn-bg-alt #282a2e;" in css
+ assert "@define-color tn-bg-high #373b41;" in css
+ assert "@define-color tn-fg #c5c8c6;" in css
+ assert "@define-color tn-fg-dim #969896;" in css
+ assert "@define-color tn-fg-muted #707880;" in css
+ assert "@define-color tn-blue #81a2be;" in css
+ assert "@define-color tn-green #b5bd68;" in css
+ assert "@define-color tn-yellow #f0c674;" in css
+ assert "@define-color tn-red #cc6666;" in css
+ assert "@define-color tn-magenta #b294bb;" in css
+ assert "@define-color tn-cyan #8abeb7;" in css
+ # alpha variants computed from background (29,31,33)
+ assert "@define-color tn-bg-a90 rgba(29, 31, 33, 0.90);" in css
+ assert "@define-color tn-bg-a95 rgba(29, 31, 33, 0.95);" in css
+ assert "@define-color tn-bg-a96 rgba(29, 31, 33, 0.96);" in css
+
+
+def test_render_mako_config():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ cfg = gen.render_mako_config(pal)
+ assert "background-color=#1d1f21" in cfg
+ assert "text-color=#c5c8c6" in cfg
+ assert "border-size=2" in cfg
+ assert "border-color=#81a2be" in cfg
+ assert "default-timeout=4000" in cfg
+
+
+def test_render_hyprlock_colors():
+ pal = gen.load_palette(os.path.join(os.path.dirname(__file__), "colors.json"))
+ out = gen.render_hyprlock_colors(pal)
+ assert "$tn_fg = rgba(c5c8c6ff)" in out
+ assert "$tn_fg_dim = rgba(969896ff)" in out
+ assert "$tn_bg_alt = rgba(282a2eff)" in out
+ assert "$tn_blue = rgba(81a2beff)" in out
+ assert "$tn_red = rgba(cc6666ff)" in out
+ assert "$tn_green = rgba(b5bd68ff)" in out
+
+
+if __name__ == "__main__":
+ test_load_palette_reads_colors_json()
+ test_hex_to_hyprlock_rgba()
+ test_hex_to_rgb_tuple()
+ test_render_colors_css()
+ test_render_mako_config()
+ test_render_hyprlock_colors()
+ print("OK")
diff --git a/waybar/config.jsonc b/waybar/config.jsonc
index 20da5a1..ec117e9 100644
--- a/waybar/config.jsonc
+++ b/waybar/config.jsonc
@@ -12,6 +12,7 @@
"custom/fan-profile",
"pulseaudio",
"network",
+ "bluetooth",
"battery",
"tray",
],
@@ -82,6 +83,17 @@
"on-click": "networkmanager_dmenu",
},
+ "bluetooth": {
+ "format": " ",
+ "format-disabled": "",
+ "format-off": "",
+ "format-connected": " {num_connections}",
+ "tooltip-format": "{controller_alias}\n{status}",
+ "tooltip-format-connected": "{controller_alias}\n{num_connections} connected\n{device_enumerate}",
+ "tooltip-format-enumerate-connected": "{device_alias}",
+ "on-click": "blueman-manager",
+ },
+
"pulseaudio": {
"format": " {volume}%",
"format-muted": "",
diff --git a/waybar/style.css b/waybar/style.css
index 17b8d78..d8281f4 100644
--- a/waybar/style.css
+++ b/waybar/style.css
@@ -56,3 +56,15 @@ window#waybar {
#temperature.critical {
color: @tn-red;
}
+
+#bluetooth {
+ padding: 0 12px;
+}
+
+#bluetooth.connected {
+ color: @tn-blue;
+}
+
+#bluetooth.disabled, #bluetooth.off {
+ color: @tn-fg-muted;
+}