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; +}