From 39d396872f04d2c2145fe6afd71406b8326d17a1 Mon Sep 17 00:00:00 2001 From: Phundahl <89451493+Phundahl@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:38:41 +0200 Subject: [PATCH 1/2] feat(theme): tailTUI theme schema, Omarchy template and live reload Phase 27 taught the loader to read Omarchy's palette, but some mappings are genuinely undecidable in code. osaka-jade defines yellow as #459451 -- a green -- so mapping yellow to the warning role makes exit-node markers nearly indistinguishable from the online color. Preferring orange instead breaks retro-82, where orange is the accent. There is no rule that is right for every theme, so hand the decision to the person looking at the screen. - Add tailTUI's own theme schema: one key per Theme field, tried ahead of both Omarchy schemas, and probed as tailtui.toml before colors.toml in each theme directory. Its keys are disjoint from the Omarchy vocabularies, so detection stays a question of which keys appear. - Ship contrib/tailtui.toml.tpl. Installed into ~/.config/omarchy/themed/, Omarchy renders it into the active theme directory on every theme switch -- the same directory the loader already probes, so no new path was needed. - Reload the palette live. styles.ThemeStamp() reports the active file's path and mtime; checkThemeCmd rides the existing refresh tick, so there is no watcher dependency. Switching the desktop theme re-colors a running tailTUI within one tick. The command returns the parsed Theme rather than applying it: styles.Apply rewrites package-level vars that View reads, so applying it off-thread would race the renderer. Update applies it on the Elm goroutine, records the stamp even when unchanged (so a theme file appearing or vanishing is noticed), and rebuilds viewport-backed overlays, whose content would otherwise keep stale ANSI colors. Zero-config behavior is unchanged: with no template installed, colors.toml is read exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +- README.md | 28 ++++++ contrib/tailtui.toml.tpl | 65 ++++++++++++ internal/styles/theme.go | 91 ++++++++++++++++- internal/styles/theme_test.go | 159 ++++++++++++++++++++++++++++++ internal/tui/model.go | 20 +++- internal/tui/poll.go | 29 ++++++ internal/tui/theme_reload_test.go | 129 ++++++++++++++++++++++++ internal/tui/update.go | 35 ++++++- 9 files changed, 550 insertions(+), 12 deletions(-) create mode 100644 contrib/tailtui.toml.tpl create mode 100644 internal/tui/theme_reload_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0ee5b71..dd952ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ The View layer is testable without a TTY: construct `tui.New()`, send a `tea.Win - **Routing Management modal (`R`) — local working copy, applied only via the Command Room.** Edits operate on a **working copy** (`Model.routingExitNode` / `routingRoutes`) snapshotted from `prefs` at open; they **must never mutate `m.prefs`** (the daemon's last-known truth). Added routes are **validated with `net.ParseCIDR`** before entering the list; invalid input is rejected (red-flashed, cleared) without crashing. A `[d]`-deleted route is stashed in `lastDeletedRoute` and **pre-filled into the next `[a]`** (a lightweight pseudo-undo / edit-typo affordance) — the pre-fill flows through the same `net.ParseCIDR` validation, so it is not a bypass. The CIDR editor is a `bubbles/textinput` (`routingInput`), styled to blend into the modal **Surface**. Two things keep it from rendering a black block (the "black box" glitch): (1) **no in-field `Placeholder`** — a placeholder makes the empty field render via `placeholderView`, which fills the remaining Width with **raw, unstyled (near-black) spaces** that no outer Surface wrap can recolor (they sit mid-line after a reset); with no placeholder the main path pads with `TextStyle` (Surface bg) instead, so every cell is Surface (the prompt label already shows the "(e.g., 192.168.1.0/24)" example). (2) the cursor is a **visible bright Primary block**: `bubbles/cursor` draws its visible cell with `Style.Reverse(true)`, which swaps fg/bg at display time, so the **displayed background is `Style`'s Foreground** — set `Cursor.Style` = `Foreground(Primary).Background(Bg)` so the reverse yields a solid Primary block (dark glyph), neither invisible (the 23.2 `Foreground(Surface)` camouflage, which blended into the modal) nor a black block. `Cursor.TextStyle = ModalText` keeps the blink-"off" phase as normal text on Surface. (`PromptStyle`/`TextStyle` also paint Surface.) While in input mode an early guard in `updateOverlay` routes **every** key (including `esc`/`q`) to `updateRoutingInput`, so the global close handler can't fire mid-entry (`Esc` cancels the entry; `Enter` confirms). `routingDirty` prevents a late `prefsMsg` from clobbering staged edits; `refreshRoutingOverlay` re-renders **and** resizes the viewport when switching sub-modes so the editor is never clipped. - **Routing execution — the "Command Room" (`stateRoutingConfirm`, Phase 23).** The **only** place routing changes reach the daemon. `Enter` in the routing list opens a floating confirmation overlay (`renderRoutingConfirmOverlay`, rendered directly from model state like the settings modal) showing the **exact command** to be run — assembled by `tailscale.AdvertiseCommandString(exitNode, routes)` and identical to what executes — plus the **Admin Console approval reminder**. The command **always sets both flags** (`--advertise-exit-node=` and `--advertise-routes=`); an empty routes list renders `--advertise-routes=` (the idiomatic "clear", never omit). Long previews wrap safely (`wrapCommand` = word-wrap + hard-wrap fallback for the comma-joined CIDR token). Keys: **`Enter`** applies — fires `setRoutingCmd` → `tailscale.SetRouting` **off the UI thread**, closes the modal to `stateMain`, and the `routingActionMsg` logs the executed command + batches `fetchStatusCmd`+`fetchPrefsCmd`; **`c`/`C`** copies the command to the system clipboard via `copyRoutingCmd` (async, **`github.com/atotto/clipboard`** — shells out to pbcopy/wl-copy/xclip/clip, so a missing tool logs an error instead of crashing) and flashes a `✓ Copied!` indicator (`routingCopied`, set by `clipboardMsg`), staying in the modal; **`Esc`/`q`** go **back to the routing list** (not all the way out) without applying. `updateOverlay` routes `stateRoutingConfirm` keys to `updateRoutingConfirm` *before* the global esc/q close handler so Esc means "back," not "quit the feature." -- **Theme Engine (no more strict-ANSI rule).** Colors are TrueColor hex, centralized in a `styles.Theme` struct (`internal/styles/theme.go`). `DefaultTheme()` is the **"Matrix Core"** master design — the EXACT hex codes from the style guide's YAML frontmatter (`_designs/00_STYLE_GUIDE.md`): `primary #6bfb9a`, `background #0e150f`, `surface-container #1a211b` (Surface), `surface-bright #333b34`, `outline-variant #3d4a3e` (borders), `on-surface #dde5da` (text), `outline #869486` (dim), `tertiary #ffdd75` (warning), `error #ffb4ab`. `LoadTheme()` reads the **native Omarchy** theme, parsed with `github.com/pelletier/go-toml/v2`. It handles **two schema generations and two locations** (Phase 27): `themeCandidates()` probes `~/.local/state/omarchy/current/theme/colors.toml` (**Omarchy 4 "Quattro"** — it moved the current-theme symlink out of `~/.config`) then `~/.config/omarchy/current/theme/colors.toml` (**Omarchy ≤ 3**); `TAILTUI_THEME` short-circuits to a single candidate. `loadThemeFile` picks the mapping by **which keys are present** (`hasMarkers()` on each wire struct), never by filename — so a `TAILTUI_THEME` override of either vintage works, and `ThemePath()` reports the file that will actually be read. Mapping is per-field, so any missing key keeps its default; an unreadable/malformed/unrecognized file falls through to the next candidate and finally to `DefaultTheme()` — never crashes or leaves blanks. **v4 (semantic) → Theme**: `accent`→PrimaryAccent, `green`→SecondaryAccent, `background`→Background, `lighter_background`→Surface **in dark mode** / `dark_background`→Surface **in light mode**, `selection`→SurfaceBright, `muted`→BorderInactive+TextDim, `foreground`→TextNormal, `yellow` (falling back to `orange`, which themes like `white` need)→Warning, `red`→Error. **Legacy (flat palette) → Theme**: `accent`→PrimaryAccent, `color2`→SecondaryAccent, `background`→Background, `color0`→Surface, `color8`→SurfaceBright/BorderInactive/TextDim, `foreground`→TextNormal, `color3`→Warning, `color1`→Error. A file carrying **only** the three keys both schemas share (`accent`/`foreground`/`background`) is ambiguous-but-usable: those three apply, the rest default. **`Theme.Mode`** (`ModeDark`/`ModeLight`, from the v4 `mode` key; legacy and the default are always dark) exists solely to flip the **surface-ladder direction** — the elevated surface must move *away* from the canvas (lighter than a dark background, shaded against a light one), so taking `lighter_background` for a light theme is the bug the mode key prevents. Don't add a second consumer of `Mode` without re-checking that invariant. `main` calls `styles.Apply(styles.LoadTheme())` at startup. `Apply` rebuilds the package color vars (`Primary`, `Secondary`, `Subtle`, `Warn`, `Danger`, `Fg`, `Bg`, `Surface`, `SurfaceBright`, `BorderInactive`) and derived styles; helper funcs (`Pane`, `Divider`, `LatencyGraph`, …) read them at call time. Add new colors as `Theme` fields, not raw codes. Hex degrades to ANSI on non-TrueColor terminals. +- **Theme Engine (no more strict-ANSI rule).** Colors are TrueColor hex, centralized in a `styles.Theme` struct (`internal/styles/theme.go`). `DefaultTheme()` is the **"Matrix Core"** master design — the EXACT hex codes from the style guide's YAML frontmatter (`_designs/00_STYLE_GUIDE.md`): `primary #6bfb9a`, `background #0e150f`, `surface-container #1a211b` (Surface), `surface-bright #333b34`, `outline-variant #3d4a3e` (borders), `on-surface #dde5da` (text), `outline #869486` (dim), `tertiary #ffdd75` (warning), `error #ffb4ab`. `LoadTheme()` reads the **native Omarchy** theme, parsed with `github.com/pelletier/go-toml/v2`. It handles **two schema generations and two locations** (Phase 27): `themeCandidates()` probes `~/.local/state/omarchy/current/theme/colors.toml` (**Omarchy 4 "Quattro"** — it moved the current-theme symlink out of `~/.config`) then `~/.config/omarchy/current/theme/colors.toml` (**Omarchy ≤ 3**); `TAILTUI_THEME` short-circuits to a single candidate. `loadThemeFile` picks the mapping by **which keys are present** (`hasMarkers()` on each wire struct), never by filename — so a `TAILTUI_THEME` override of either vintage works, and `ThemePath()` reports the file that will actually be read. Mapping is per-field, so any missing key keeps its default; an unreadable/malformed/unrecognized file falls through to the next candidate and finally to `DefaultTheme()` — never crashes or leaves blanks. **v4 (semantic) → Theme**: `accent`→PrimaryAccent, `green`→SecondaryAccent, `background`→Background, `lighter_background`→Surface **in dark mode** / `dark_background`→Surface **in light mode**, `selection`→SurfaceBright, `muted`→BorderInactive+TextDim, `foreground`→TextNormal, `yellow` (falling back to `orange`, which themes like `white` need)→Warning, `red`→Error. **Legacy (flat palette) → Theme**: `accent`→PrimaryAccent, `color2`→SecondaryAccent, `background`→Background, `color0`→Surface, `color8`→SurfaceBright/BorderInactive/TextDim, `foreground`→TextNormal, `color3`→Warning, `color1`→Error. A file carrying **only** the three keys both schemas share (`accent`/`foreground`/`background`) is ambiguous-but-usable: those three apply, the rest default. **`Theme.Mode`** (`ModeDark`/`ModeLight`, from the v4 `mode` key; legacy and the default are always dark) exists solely to flip the **surface-ladder direction** — the elevated surface must move *away* from the canvas (lighter than a dark background, shaded against a light one), so taking `lighter_background` for a light theme is the bug the mode key prevents. Don't add a second consumer of `Mode` without re-checking that invariant. **tailTUI's own schema (Phase 30) is tried first** and outranks both Omarchy schemas: `tailtuiTheme` has one key per `Theme` field (`mode`, `primary`, `secondary`, `background`, `surface`, `surface_bright`, `border`, `text`, `text_dim`, `warning`, `error`) whose names are deliberately **disjoint** from the Omarchy vocabularies (only `mode`/`background` are shared, and both are excluded from `hasMarkers()`), so three-way detection stays purely a question of which keys appear. Within each theme directory `tailtui.toml` is probed **before** `colors.toml` — it is opt-in, so it is the more deliberate statement. It is what `contrib/tailtui.toml.tpl` renders to when installed into `~/.config/omarchy/themed/`, and it is equally hand-writable on any distro; **the reason it exists is to hand irreducible mapping calls back to the user** (e.g. `osaka-jade` defines `yellow` as a green, so no loader heuristic can pick a Warning color that is right for every theme). **Live reload (Phase 30)**: `styles.ThemeStamp()` returns the active file's path+mtime; `checkThemeCmd` (batched onto the existing `refreshInterval` tick, so no watcher dependency) re-stats it off the UI thread and re-parses on change, returning a `themeMsg`. **The parsed `Theme` is returned, never applied, inside that closure** — `styles.Apply` rewrites package-level vars that `View` reads, so it must run in `Update` on the single Elm goroutine or it races the renderer. The `themeMsg` handler applies the palette, records the new stamp (**even when unchanged**, so a theme file appearing or vanishing is noticed), logs once, and calls `resizeOverlay()` when an overlay is open — viewport-backed overlays hold pre-rendered strings with the old colors baked in as ANSI codes, unlike the dashboard which re-renders every frame. `main` calls `styles.Apply(styles.LoadTheme())` at startup, and `New()` seeds the stamp from the same file so the first tick reports no spurious change. `Apply` rebuilds the package color vars (`Primary`, `Secondary`, `Subtle`, `Warn`, `Danger`, `Fg`, `Bg`, `Surface`, `SurfaceBright`, `BorderInactive`) and derived styles; helper funcs (`Pane`, `Divider`, `LatencyGraph`, …) read them at call time. Add new colors as `Theme` fields, not raw codes. Hex degrades to ANSI on non-TrueColor terminals. - **Elm architecture.** Standard Bubble Tea Model/Update/View; keep them split across `internal/tui/{model,update,view}.go`. ## Architecture @@ -52,7 +52,8 @@ main.go entry point: tea.NewProgram(tui.New(), WithAltScreen internal/ types/types.go domain models (Peer, LocalStatus, enums) — CLI-agnostic tailscale/tailscale.go live adapter: status/ping/set/switch/login/logout → types structs - styles/theme.go Theme struct, DefaultTheme (Matrix Core), dual-schema Omarchy TOML loader + styles/theme.go Theme struct, DefaultTheme (Matrix Core), tri-schema TOML loader + ThemeStamp +contrib/tailtui.toml.tpl Omarchy theme template (optional; renders tailtui.toml on theme switch) styles/styles.go theme-derived styles (Apply), Divider/Bar/LatencyGraph helpers styles/pane.go Pane(): sharp single-line box with title in the top border tui/ @@ -168,6 +169,7 @@ Before changing any interaction, consult the keybinding matrix and overlay specs - **Phase 27 — Omarchy 4 ("Quattro") theme support: dual location, dual schema & light mode** — the theme loader had gone silently dead on upgraded machines. Omarchy 4 changed **both** halves of the contract: the current-theme symlink moved `~/.config/omarchy/current/theme/` → `~/.local/state/omarchy/current/theme/`, and `colors.toml` swapped its flat terminal palette (`color0`–`color15`) for **semantic slots** (`mode`, `selection`, `muted`, `lighter_background`, `dark_background`, `red`/`yellow`/`orange`/`green`, …). `LoadTheme` found nothing, fell back to Matrix Core without a word, and the UI stopped tracking the desktop. **(1) Path probing**: `themeCandidates()` returns the v4 location then the legacy one (or a lone `TAILTUI_THEME` override); `LoadTheme` walks them and takes the first that yields a usable palette, and `ThemePath()` now reports the file actually read rather than a hardcoded guess. **(2) Schema detection**: `omarchyV4` and `omarchyLegacy` wire structs each expose `hasMarkers()`, checking only the keys **exclusive** to that generation — the three shared keys (`accent`/`foreground`/`background`) are deliberately excluded so they can't misclassify a file; detection is by content, not filename, so an override of either vintage maps correctly. A file with only the shared three still applies them and defaults the rest. **(3) Light mode**: new `Theme.Mode` field (`ModeDark`/`ModeLight`) read from the v4 `mode` key, used to flip which slot becomes `Surface` — `lighter_background` when dark, `dark_background` when light — because the elevated surface has to move away from the canvas in whichever direction the canvas sits. Light themes (`catppuccin-latte`, `flexoki-light`, `white`) previously rendered as an inverted mess. **(4) Warning slot**: `yellow`, falling back to `orange` for the themes that omit it. **Tests** (`internal/styles/theme_test.go`, the package's first): verbatim fixtures from real shipped themes cover v4 dark, v4 light (asserting `Surface` takes `dark_background` and *not* `lighter_background`), the legacy schema, orange fallback, partial/ambiguous files, malformed/empty/missing files, and the path-priority + override rules; `TestInstalledOmarchyThemesMapCompletely` sweeps every `colors.toml` installed on the machine (22 themes) and fails if **any** palette slot is empty or silently equal to its Matrix Core default — the tripwire for the next upstream schema change. It skips cleanly where Omarchy isn't installed. No TUI / layout / keybinding changes; `styles.Apply` and every consumer are untouched. - **Phase 28 — CI workflow & release-config repair** — `.github/workflows/release.yml` was the **only** workflow and it fires solely on `v*` tags, so nothing verified a commit until a release was already being cut, and a broken `.goreleaser.yaml` could only surface as a failed release job *after* the tag existed. Added `.github/workflows/ci.yml`: runs on pushes to `main`, on every pull request, and via `workflow_dispatch`; `concurrency` cancels superseded in-flight runs; `contents` permission is read-only. Two jobs — **`test`** (a `gofmt -l` gate, a `go mod tidy` drift gate, `go build ./...`, `go vet ./...`, and **`go test -race ./...`**; the View layer is TTY-free so the suite runs headless, and `-race` covers the async `tea.Cmd` status/ping polling paths) and **`goreleaser-config`** (`goreleaser check`, so config errors fail on a PR instead of on a tag). Both resolve Go via `go-version-file: go.mod` rather than `stable`, so CI tracks the pinned toolchain instead of drifting on release day. **The new check immediately caught three real defects** in `.goreleaser.yaml`, every one of which would have failed the release job only after a tag was pushed: `nfpms.files` → **`nfpms.contents`** (`files` is a v1 key and is rejected outright by the v2 schema — the hard failure); `archives.format` → **`archives.formats`** (deprecated singular); and `snapshot.name_template` → **`snapshot.version_template`** (deprecated — note `goreleaser check` exits non-zero on deprecations, not just schema errors). Also dropped `-extldflags "{{.Env.LDFLAGS}}"` from the build ldflags — it required an `LDFLAGS` env var to be set at release time and is a no-op under `CGO_ENABLED=0` (no external linker to pass it to) — and translated the one non-English comment. **Test hermeticity is a requirement for CI to stay green**: every styles test uses `t.Setenv` for `HOME`/`TAILTUI_THEME`, and `TestInstalledOmarchyThemesMapCompletely` (which globs the machine's real Omarchy theme dir) **skips** when none is installed. Don't add a test that assumes Omarchy, a TTY, or a live `tailscale` daemon without an equivalent skip guard. - **Phase 29 — Release v1.2.0 & version plumbing** — cut the release covering everything since the (never-published) `v1.1.0` tag: the 24.x sudo account flows, Phase 26 mock mode + VHS demo, the 26.2 privacy pass, Phase 27's Omarchy 4 theme support, and Phase 28's CI. **Version plumbing fix**: `.goreleaser.yaml` stamps `-X main.version={{.Version}}`, but `main.go` had **no `version` var** and `appVersion` was a `const` in `internal/tui/view.go` — so the ldflag silently did nothing (Go ignores `-X` on a missing symbol) and a tagged build would have shipped whatever was hardcoded. Now `main.go` declares `var version string` (empty for `go build`/`go run`) and calls **`tui.SetVersion(version)`** first thing in `main`; `appVersion` became a **var** (dev-build literal `v1.2.0`) and `SetVersion` ignores an empty value and normalizes a bare `1.2.0` → `v1.2.0`, since goreleaser's `{{.Version}}` drops the prefix. `appVersion` remains the single definition — set it there, never duplicate it. Added `internal/tui/version_test.go` (normalization table incl. prerelease + empty, plus a render test asserting the stamped version actually reaches the footer while staying flush). `.gitignore` was rewritten to cover the compiled `/tailtui` binary (previously **committed**, ~6.5 MB, removed from history in the same cycle), test/coverage output, and editor/OS noise, while preserving the existing `_designs/` allow-list. README gained a `ci` status badge under the tagline and a "What's New in v1.2.0" section above the v1.1.0 one. No layout / state / keybinding changes. +- **Phase 30 — tailTUI theme schema, Omarchy template & live theme reload** — closed the one gap Phase 27 could not: some mappings are genuinely undecidable in code. `osaka-jade` defines `yellow = #459451` (a green), so mapping `yellow`→Warning makes exit-node markers nearly indistinguishable from the online green — but preferring `orange` breaks `retro-82`, where orange *is* the accent. **(1) tailTUI's own schema**: a third wire struct `tailtuiTheme` with one key per `Theme` field, tried **before** both Omarchy schemas, and probed as `tailtui.toml` **ahead of** `colors.toml` inside each theme directory. Keys are disjoint from the Omarchy vocabularies so detection stays key-based; `mode`/`background` are shared and therefore excluded from `hasMarkers()`. **(2) Omarchy template**: `contrib/tailtui.toml.tpl`, installed by the user into `~/.config/omarchy/themed/`. Omarchy's `omarchy-theme-set-templates` renders every `*.tpl` there into the **active theme directory** (`$NEXT_THEME_DIR/`, which becomes `~/.local/state/omarchy/current/theme/`) on every theme switch — i.e. straight into the directory the loader already probes, so no new path was needed. Templates get the full palette (semantic slots, `color0`–`color15`, `mode`, `bright_`/`dark_`/`light_` variants) plus `_strip`/`_rgb` modifiers and a `{{ mix a b 30% }}` blend. **(3) Live reload**: `styles.ThemeStamp()` (path+mtime) plus `checkThemeCmd`, batched onto the **existing** `refreshInterval` tick — no fsnotify, no new dependency — so switching the desktop theme re-colors a running tailTUI within one tick. The critical constraint: the command **returns** the parsed `Theme` and never calls `styles.Apply` itself, because `Apply` rewrites package-level vars `View` reads; applying happens in `Update`, on the Elm goroutine. The handler records the stamp even on no-change (so a file appearing/vanishing is caught) and rebuilds viewport-backed overlays via `resizeOverlay()`, whose content would otherwise keep stale ANSI colors. Zero-config behavior is untouched: with no template installed, `colors.toml` is read exactly as before. **Tests**: own-schema mapping, light mode, three-way detection non-ambiguity, `tailtui.toml`-over-`colors.toml` precedence, fallback when no template exists, `ThemeStamp` change tracking and empty state (`theme_test.go`); palette applied + stamp recorded + logged, unchanged-is-quiet, overlay rebuilt (forcing TrueColor, since a TTY-less test strips every SGR and both renders would otherwise compare equal), and `New()` stamp seeding (`theme_reload_test.go`). - **Upcoming (next major cycle — see the README Roadmap)** — - **Tailscale Serve & Funnel management**: visual port forwarding to securely expose local services to the tailnet (`tailscale serve`) or the public internet (`tailscale funnel`), driven from keyboard overlays in the existing modal style. - **Connection diagnostics**: deep-dive into peer connection health — DERP-relay vs. direct routing and the signals to debug a flaky link (likely from `tailscale status --json` endpoints + `tailscale ping`/`netcheck`). diff --git a/README.md b/README.md index a7c5a1b..c2917a4 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,30 @@ Light themes are honored too: `tailTUI` reads the theme's `mode` key and shades its panels and modals in the right direction, so a light palette renders as a light UI rather than an inverted one. +### Live theme switching (optional) + +Install the bundled Omarchy template and `tailTUI` re-colors itself **while +running**, within a few seconds of you switching themes — no restart: + +```bash +mkdir -p ~/.config/omarchy/themed +cp contrib/tailtui.toml.tpl ~/.config/omarchy/themed/ +``` + +Omarchy renders the template into the active theme directory on every switch, +and `tailTUI` notices the file change on its next refresh tick. + +Live reload is only half the reason to install it. The other half is control: +the template decides which palette slot drives which part of the UI, so you can +fix mappings that no automatic rule can get right for every theme. For example +`osaka-jade` defines its `yellow` as a green, which leaves exit-node markers +nearly indistinguishable from the online color — one line in the template +(`warning = "{{ orange }}"`) fixes it, for your themes, permanently. + +The template is fully commented with every available placeholder. And it stays +optional: without it, `tailTUI` reads `colors.toml` and maps the palette itself, +exactly as before. + **The Omarchy binding is purely cosmetic, not a requirement.** `tailTUI` is a stock [Bubble Tea](https://github.com/charmbracelet/bubbletea) program, so the default "Matrix Core" palette renders beautifully on any modern desktop @@ -237,6 +261,10 @@ the keys present rather than by filename: `red`, `yellow`, `orange`, `green` - **Omarchy 3** — flat terminal palette: `accent`, `foreground`, `background`, `color0`–`color15` +- **tailTUI's own** — one key per UI role: `mode`, `primary`, `secondary`, + `background`, `surface`, `surface_bright`, `border`, `text`, `text_dim`, + `warning`, `error`. This is what the template above generates, and it is + the format to hand-write if you want full control on any distro. Mapping is per-key, so a partial or unusual palette keeps the "Matrix Core" default for whatever it omits — never a blank or a crash. All colors are diff --git a/contrib/tailtui.toml.tpl b/contrib/tailtui.toml.tpl new file mode 100644 index 0000000..a32c50a --- /dev/null +++ b/contrib/tailtui.toml.tpl @@ -0,0 +1,65 @@ +# tailTUI theme template for Omarchy +# +# Install: +# mkdir -p ~/.config/omarchy/themed +# cp contrib/tailtui.toml.tpl ~/.config/omarchy/themed/ +# +# Omarchy renders this into the active theme directory as `tailtui.toml` every +# time you switch themes, and tailTUI picks it up within a few seconds — no +# restart. Without it, tailTUI reads the theme's own `colors.toml` directly and +# maps the palette itself, which needs no setup at all. +# +# The point of installing it is control: you decide which palette slot drives +# which part of the UI, per theme, instead of tailTUI guessing. See below for +# where that matters. +# +# Every {{ placeholder }} is substituted by Omarchy from the current theme. +# Available names include the semantic slots (accent, background, foreground, +# selection, muted, red, green, yellow, orange, blue, cyan, magenta, and the +# bright_/dark_/light_ variants), the legacy terminal palette (color0-color15), +# and `mode`. Modifiers: {{ x_strip }} drops the leading #, {{ x_rgb }} gives +# decimal "r,g,b", and {{ mix a b 30% }} blends two colors. +# +# Run `omarchy-theme-color --file ~/.local/state/omarchy/current/theme/colors.toml --all` +# to print every name your current theme defines. + +# "dark" or "light". Controls which direction panels are shaded relative to the +# background: a light theme shades its surfaces down, a dark theme lifts them up. +mode = "{{ mode }}" + +# Focus, borders on the active pane, buttons, key hints. +primary = "{{ accent }}" + +# Online nodes, approved routes, success chips. +secondary = "{{ green }}" + +# The base canvas. +background = "{{ background }}" + +# Elevated panels and modals — one step away from the canvas. On a light theme +# swap this for {{ dark_background }} so panels shade downward instead of up. +surface = "{{ lighter_background }}" + +# The selected row's highlight bar. +surface_bright = "{{ selection }}" + +# Unfocused pane borders and dividers. +border = "{{ muted }}" + +# Body text. +text = "{{ foreground }}" + +# Labels, timestamps, secondary text. +text_dim = "{{ muted }}" + +# Exit nodes, relayed connections, elevated latency. +# +# `yellow` is the obvious choice, but some themes define it as something that +# collides with `green` — osaka-jade sets yellow to #459451, which would make +# exit-node markers nearly indistinguishable from the online color. If that +# happens in your theme, use {{ orange }} here instead. This is exactly the +# judgement call a template exists to hand back to you. +warning = "{{ yellow }}" + +# Conflicts, failures, critical latency. +error = "{{ red }}" diff --git a/internal/styles/theme.go b/internal/styles/theme.go index f82fbb6..4a23071 100644 --- a/internal/styles/theme.go +++ b/internal/styles/theme.go @@ -3,6 +3,7 @@ package styles import ( "os" "path/filepath" + "time" "github.com/charmbracelet/lipgloss" "github.com/pelletier/go-toml/v2" @@ -52,6 +53,55 @@ func DefaultTheme() Theme { } } +// tailtuiTheme is tailTUI's own theme schema — one key per Theme field, so a +// palette can be stated directly instead of inferred from someone else's +// vocabulary. It is what an Omarchy template renders to (see +// contrib/tailtui.toml.tpl), and it is equally hand-writable on any distro. +// +// Its keys are deliberately disjoint from the Omarchy schemas' (except the +// shared `mode`), so schema detection stays a matter of which names appear. +type tailtuiTheme struct { + Mode string `toml:"mode"` + Primary string `toml:"primary"` + Secondary string `toml:"secondary"` + Background string `toml:"background"` + Surface string `toml:"surface"` + SurfaceBright string `toml:"surface_bright"` + Border string `toml:"border"` + Text string `toml:"text"` + TextDim string `toml:"text_dim"` + Warning string `toml:"warning"` + Error string `toml:"error"` +} + +// hasMarkers reports whether the file uses tailTUI's schema. `mode` and +// `background` are shared with the Omarchy schemas and so are excluded. +func (t tailtuiTheme) hasMarkers() bool { + return t.Primary != "" || t.Secondary != "" || t.Surface != "" || + t.SurfaceBright != "" || t.Border != "" || t.Text != "" || + t.TextDim != "" || t.Warning != "" || t.Error != "" +} + +// mapTailtui maps tailTUI's own schema onto the Theme. Every key is optional; +// whatever a file omits keeps its Matrix Core default. +func mapTailtui(o tailtuiTheme) Theme { + t := DefaultTheme() + if o.Mode == ModeLight { + t.Mode = ModeLight + } + set(&t.PrimaryAccent, o.Primary) + set(&t.SecondaryAccent, o.Secondary) + set(&t.Background, o.Background) + set(&t.Surface, o.Surface) + set(&t.SurfaceBright, o.SurfaceBright) + set(&t.BorderInactive, o.Border) + set(&t.TextNormal, o.Text) + set(&t.TextDim, o.TextDim) + set(&t.Warning, o.Warning) + set(&t.Error, o.Error) + return t +} + // omarchyV4 mirrors the Omarchy 4 ("Quattro") colors.toml schema: semantically // named slots rather than a raw terminal palette. Only the fields we map are // declared — go-toml ignores the rest (cyan/blue/magenta/brown/bright_*). @@ -116,10 +166,20 @@ func themeCandidates() []string { if err != nil { return nil } - return []string{ - filepath.Join(home, ".local", "state", "omarchy", "current", "theme", "colors.toml"), // Omarchy 4+ - filepath.Join(home, ".config", "omarchy", "current", "theme", "colors.toml"), // Omarchy <= 3 + // Within each theme directory, tailtui.toml wins over colors.toml: it is + // opt-in (someone installed a template or wrote it), so it is the more + // deliberate statement of intent. + var paths []string + for _, dir := range []string{ + filepath.Join(home, ".local", "state", "omarchy", "current", "theme"), // Omarchy 4+ + filepath.Join(home, ".config", "omarchy", "current", "theme"), // Omarchy <= 3 + } { + paths = append(paths, + filepath.Join(dir, "tailtui.toml"), + filepath.Join(dir, "colors.toml"), + ) } + return paths } // ThemePath returns the colors.toml that LoadTheme will actually read: the @@ -139,6 +199,20 @@ func ThemePath() string { return candidates[0] } +// ThemeStamp identifies the theme file LoadTheme would currently read, by path +// and modification time. Callers poll it to notice a theme switch: Omarchy +// rewrites the theme directory on every switch, so both the path and the mtime +// can change. Zero values mean no theme file is present, which is itself a +// state worth detecting (a theme file appearing should take effect). +func ThemeStamp() (path string, mod time.Time) { + for _, p := range themeCandidates() { + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + return p, fi.ModTime() + } + } + return "", time.Time{} +} + // LoadTheme returns the system (Omarchy) theme if one can be found and parsed, // otherwise it silently falls back to the default Matrix Core theme. It // understands both the Omarchy 4 semantic schema and the legacy color0..15 @@ -165,9 +239,18 @@ func loadThemeFile(path string) (Theme, bool) { return Theme{}, false // no theme file (or unreadable) — try the next } + // tailTUI's own schema is the most specific, so it is tried first. + var own tailtuiTheme + if err := toml.Unmarshal(data, &own); err != nil { + return Theme{}, false // malformed TOML — don't crash + } + if own.hasMarkers() { + return mapTailtui(own), true + } + var v4 omarchyV4 if err := toml.Unmarshal(data, &v4); err != nil { - return Theme{}, false // malformed TOML — don't crash + return Theme{}, false } if v4.hasMarkers() { return mapV4(v4), true diff --git a/internal/styles/theme_test.go b/internal/styles/theme_test.go index 9065fa0..d8623bb 100644 --- a/internal/styles/theme_test.go +++ b/internal/styles/theme_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) // Real fixtures, copied verbatim from shipped Omarchy themes so a schema drift @@ -339,3 +340,161 @@ func TestInstalledOmarchyThemesMapCompletely(t *testing.T) { }) } } + +// tailTUI's own schema, as an Omarchy template renders it. +const ownTOML = ` +mode = "dark" +primary = "#509475" +secondary = "#549e6a" +background = "#111c18" +surface = "#23372B" +surface_bright = "#32473B" +border = "#53685B" +text = "#C1C497" +text_dim = "#53685B" +warning = "#a2734b" +error = "#FF5345" +` + +func TestLoadThemeOwnSchema(t *testing.T) { + got := loadOverride(t, ownTOML) + + want := map[string]struct{ field, value string }{ + "Mode": {got.Mode, ModeDark}, + "PrimaryAccent": {string(got.PrimaryAccent), "#509475"}, + "SecondaryAccent": {string(got.SecondaryAccent), "#549e6a"}, + "Background": {string(got.Background), "#111c18"}, + "Surface": {string(got.Surface), "#23372B"}, + "SurfaceBright": {string(got.SurfaceBright), "#32473B"}, + "BorderInactive": {string(got.BorderInactive), "#53685B"}, + "TextNormal": {string(got.TextNormal), "#C1C497"}, + "TextDim": {string(got.TextDim), "#53685B"}, + "Warning": {string(got.Warning), "#a2734b"}, + "Error": {string(got.Error), "#FF5345"}, + } + for name, c := range want { + if c.field != c.value { + t.Errorf("%s = %q, want %q", name, c.field, c.value) + } + } +} + +// The whole point of the template: the user resolves mapping calls we cannot. +// osaka-jade defines yellow as a green, so a template can route warning to +// orange instead — something no heuristic in the loader could decide. +func TestOwnSchemaOverridesAmbiguousMapping(t *testing.T) { + viaColors := loadOverride(t, v4DarkTOML) + if string(viaColors.Warning) != "#459451" { + t.Fatalf("colors.toml Warning = %q, want the theme's green-ish yellow", viaColors.Warning) + } + viaOwn := loadOverride(t, ownTOML) + if string(viaOwn.Warning) != "#a2734b" { + t.Errorf("tailtui.toml Warning = %q, want the orange the template chose", viaOwn.Warning) + } +} + +func TestOwnSchemaLightMode(t *testing.T) { + got := loadOverride(t, "mode = \"light\"\nprimary = \"#0c6b3d\"\nsurface = \"#e3e4e8\"\n") + if got.Mode != ModeLight { + t.Errorf("Mode = %q, want %q", got.Mode, ModeLight) + } + if string(got.Surface) != "#e3e4e8" { + t.Errorf("Surface = %q, want %q", got.Surface, "#e3e4e8") + } +} + +// Detection is by key, not filename, so the three schemas must not be confused +// for one another regardless of where the file came from. +func TestSchemaDetectionIsUnambiguous(t *testing.T) { + cases := []struct { + name, content, marker, want string + }{ + {"own schema", ownTOML, "PrimaryAccent", "#509475"}, + {"omarchy v4", v4DarkTOML, "PrimaryAccent", "#509475"}, + {"legacy", legacyTOML, "PrimaryAccent", "#7aa2f7"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := string(loadOverride(t, c.content).PrimaryAccent); got != c.want { + t.Errorf("%s = %q, want %q", c.marker, got, c.want) + } + }) + } + // The own-schema fixture must not be mistaken for an Omarchy one: it maps + // surface directly rather than deriving it from lighter_background. + if got := loadOverride(t, ownTOML); string(got.Surface) != "#23372B" { + t.Errorf("Surface = %q — own schema was parsed as an Omarchy file?", got.Surface) + } +} + +// tailtui.toml is opt-in, so it outranks colors.toml in the same directory. +func TestThemePathPrefersOwnSchemaInThemeDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("TAILTUI_THEME", "") + + dir := filepath.Join(home, ".local", "state", "omarchy", "current", "theme") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + for name, content := range map[string]string{ + "colors.toml": v4DarkTOML, + "tailtui.toml": ownTOML, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + + want := filepath.Join(dir, "tailtui.toml") + if got := ThemePath(); got != want { + t.Errorf("ThemePath() = %q, want %q", got, want) + } + // The distinguishing value: only the own-schema fixture sets warning to orange. + if got := LoadTheme(); string(got.Warning) != "#a2734b" { + t.Errorf("Warning = %q, want tailtui.toml's %q", got.Warning, "#a2734b") + } +} + +// Falling back to colors.toml when no template is installed is what keeps the +// zero-config path working. +func TestThemePathFallsBackToColorsWhenNoTemplate(t *testing.T) { + stubHome(t, v4DarkTOML, "") + want := filepath.Join(os.Getenv("HOME"), ".local", "state", "omarchy", "current", "theme", "colors.toml") + if got := ThemePath(); got != want { + t.Errorf("ThemePath() = %q, want %q", got, want) + } +} + +// ThemeStamp drives live reload, so it must track both which file is active and +// when it changed. +func TestThemeStampTracksFileChanges(t *testing.T) { + path := writeTheme(t, ownTOML) + t.Setenv("TAILTUI_THEME", path) + + gotPath, mod := ThemeStamp() + if gotPath != path { + t.Fatalf("ThemeStamp path = %q, want %q", gotPath, path) + } + if mod.IsZero() { + t.Fatal("ThemeStamp mod time is zero for an existing file") + } + + // A theme switch rewrites the file; the stamp must move with it. + future := mod.Add(2 * time.Second) + if err := os.Chtimes(path, future, future); err != nil { + t.Fatalf("chtimes: %v", err) + } + _, mod2 := ThemeStamp() + if !mod2.After(mod) { + t.Errorf("mod time did not advance: %v then %v", mod, mod2) + } +} + +func TestThemeStampEmptyWhenNoThemeFile(t *testing.T) { + t.Setenv("TAILTUI_THEME", filepath.Join(t.TempDir(), "absent.toml")) + path, mod := ThemeStamp() + if path != "" || !mod.IsZero() { + t.Errorf("ThemeStamp() = (%q, %v), want empty", path, mod) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 03f7b39..1b9232b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/Phundahl/tailtui/internal/styles" "github.com/Phundahl/tailtui/internal/tailscale" "github.com/Phundahl/tailtui/internal/types" ) @@ -69,6 +70,14 @@ type Model struct { // so a non-elevated session doesn't flood the ring every refresh. profilesLocked bool + // themePath/themeMod stamp the theme file the current palette was loaded + // from. Each refresh tick re-stats it (checkThemeCmd) and re-applies the + // palette when it moves, so switching the desktop theme re-colors a running + // tailTUI. Seeded in New() from the file main already applied, so the first + // check doesn't report a spurious change. + themePath string + themeMod time.Time + // Advanced Settings modal state. prefs holds the live local-node preferences // (read via tailscale.GetPrefs); settingCursor is the highlighted toggle. prefs types.Prefs @@ -123,11 +132,14 @@ func New() Model { if tailscale.MockEnabled() { latency = tailscale.MockLatencySeed() } + themePath, themeMod := styles.ThemeStamp() return Model{ - state: stateMain, - overlay: viewport.New(0, 0), // sized when an overlay is opened - peers: newPeerList(nil), - latency: latency, + state: stateMain, + themePath: themePath, + themeMod: themeMod, + overlay: viewport.New(0, 0), // sized when an overlay is opened + peers: newPeerList(nil), + latency: latency, // logs start empty (no mock seed); real events populate the ring. // accounts are fetched live (tailscale switch --list) by Init / on open. } diff --git a/internal/tui/poll.go b/internal/tui/poll.go index 8caa5ff..eaf8005 100644 --- a/internal/tui/poll.go +++ b/internal/tui/poll.go @@ -12,6 +12,7 @@ import ( "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" + "github.com/Phundahl/tailtui/internal/styles" "github.com/Phundahl/tailtui/internal/tailscale" "github.com/Phundahl/tailtui/internal/types" ) @@ -58,6 +59,34 @@ func tickCmd() tea.Cmd { }) } +// themeMsg reports one theme-file check. changed is true only when the active +// file's path or mtime moved, in which case theme carries the freshly parsed +// palette. +type themeMsg struct { + theme styles.Theme + path string + mod time.Time + changed bool +} + +// checkThemeCmd stats the active theme file off the UI thread and re-parses it +// when it has moved since prevPath/prevMod. Omarchy rewrites the theme +// directory on every switch, so this is what makes a running tailTUI follow the +// desktop theme within one refresh tick. +// +// The parsed Theme is *returned*, never applied here: styles.Apply mutates +// package-level vars that View reads, so applying it must happen in Update, on +// the single Elm goroutine. Applying it in this closure would race the renderer. +func checkThemeCmd(prevPath string, prevMod time.Time) tea.Cmd { + return func() tea.Msg { + path, mod := styles.ThemeStamp() + if path == prevPath && mod.Equal(prevMod) { + return themeMsg{path: path, mod: mod} + } + return themeMsg{theme: styles.LoadTheme(), path: path, mod: mod, changed: true} + } +} + // pingMsg carries one live latency sample (ms) for the node at ip back into the // loop. ok is false when the node didn't answer, in which case the sample is // dropped rather than recorded as a fake value. diff --git a/internal/tui/theme_reload_test.go b/internal/tui/theme_reload_test.go new file mode 100644 index 0000000..2f9783e --- /dev/null +++ b/internal/tui/theme_reload_test.go @@ -0,0 +1,129 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + + "github.com/Phundahl/tailtui/internal/styles" +) + +// restoreTheme puts the package-level palette back after a test, since +// styles.Apply mutates globals shared by every other suite in this package. +func restoreTheme(t *testing.T) { + t.Helper() + t.Cleanup(func() { styles.Apply(styles.DefaultTheme()) }) +} + +// A changed theme file must repaint a running tailTUI: the palette is applied, +// the stamp advances, and the reload is logged. +func TestThemeMsgAppliesPalette(t *testing.T) { + restoreTheme(t) + m := newReadyModel(t, 120, 40) + + next := styles.DefaultTheme() + next.PrimaryAccent = "#abcdef" + stamp := time.Now() + + updated, _ := m.Update(themeMsg{theme: next, path: "/themes/x/tailtui.toml", mod: stamp, changed: true}) + got := updated.(Model) + + if string(styles.Primary) != "#abcdef" { + t.Errorf("styles.Primary = %q, want the reloaded %q", styles.Primary, "#abcdef") + } + if got.themePath != "/themes/x/tailtui.toml" { + t.Errorf("themePath = %q, not recorded", got.themePath) + } + if !got.themeMod.Equal(stamp) { + t.Errorf("themeMod = %v, want %v", got.themeMod, stamp) + } + if len(got.logs) == 0 || !strings.Contains(got.logs[len(got.logs)-1].Message, "Theme reloaded") { + t.Error("theme reload was not logged") + } +} + +// An unchanged file must still record the stamp — so a theme file appearing (or +// vanishing) later is noticed — but must not repaint or log. +func TestThemeMsgUnchangedIsQuiet(t *testing.T) { + restoreTheme(t) + styles.Apply(styles.DefaultTheme()) + before := styles.Primary + + m := newReadyModel(t, 120, 40) + stamp := time.Now() + + updated, cmd := m.Update(themeMsg{path: "/themes/x/colors.toml", mod: stamp, changed: false}) + got := updated.(Model) + + if styles.Primary != before { + t.Errorf("palette changed on an unchanged theme: %q -> %q", before, styles.Primary) + } + if got.themePath != "/themes/x/colors.toml" || !got.themeMod.Equal(stamp) { + t.Error("stamp not recorded for an unchanged file") + } + if len(got.logs) != 0 { + t.Errorf("unchanged theme logged %d entries, want 0", len(got.logs)) + } + if cmd != nil { + t.Error("unchanged theme should issue no command") + } +} + +// Viewport-backed overlays hold pre-rendered strings with colors baked in as +// ANSI codes, so a theme change has to rebuild them rather than wait for the +// next resize. +func TestThemeMsgRebuildsOpenOverlay(t *testing.T) { + restoreTheme(t) + // Tests have no TTY, so lipgloss would strip every SGR and both renders + // would compare equal no matter what the palette did. + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + defer lipgloss.SetColorProfile(prev) + + m := newReadyModel(t, 120, 40) + + opened, _ := m.Update(key("?")) + m = opened.(Model) + if m.state != stateHelp { + t.Fatalf("state = %v, want stateHelp", m.state) + } + before := m.overlay.View() + + next := styles.DefaultTheme() + next.PrimaryAccent = "#ff00ff" + next.TextNormal = "#00ffff" + updated, _ := m.Update(themeMsg{theme: next, path: "/t/tailtui.toml", mod: time.Now(), changed: true}) + got := updated.(Model) + + if got.state != stateHelp { + t.Errorf("state = %v, overlay should stay open across a theme change", got.state) + } + if got.overlay.View() == before { + t.Error("overlay content unchanged after a theme reload — stale ANSI colors") + } + assertFlush(t, got.View(), 120, 40) +} + +// The stamp seeded in New() must match what main already applied, so the first +// tick doesn't report a spurious change and log a reload at startup. +func TestNewSeedsThemeStamp(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tailtui.toml") + if err := os.WriteFile(path, []byte("primary = \"#123456\"\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + t.Setenv("TAILTUI_THEME", path) + + m := New() + if m.themePath != path { + t.Errorf("themePath = %q, want %q", m.themePath, path) + } + if m.themeMod.IsZero() { + t.Error("themeMod not seeded — the first tick would report a false change") + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 13827ed..d69665c 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,11 +1,23 @@ package tui import ( + "path/filepath" + tea "github.com/charmbracelet/bubbletea" + "github.com/Phundahl/tailtui/internal/styles" "github.com/Phundahl/tailtui/internal/types" ) +// themeSource labels a theme file for the log line, keeping the message short +// where the path is long and predictable. +func themeSource(path string) string { + if path == "" { + return "built-in defaults" + } + return filepath.Base(path) +} + // Update implements tea.Model. // // Navigation (j/k, arrows), fuzzy filtering ("/"), and selection are delegated @@ -28,8 +40,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.applyStatus(msg) case tickMsg: - // Each tick fires the next background fetch and reschedules itself. - return m, tea.Batch(fetchStatusCmd(), tickCmd()) + // Each tick fires the next background fetch, re-checks the theme file, + // and reschedules itself. + return m, tea.Batch(fetchStatusCmd(), checkThemeCmd(m.themePath, m.themeMod), tickCmd()) + + case themeMsg: + // Record the stamp even when nothing changed, so a file appearing or + // disappearing is picked up on the next tick. + m.themePath, m.themeMod = msg.path, msg.mod + if !msg.changed { + return m, nil + } + // Apply on the Update goroutine: styles.Apply rewrites package-level + // vars that View reads. + styles.Apply(msg.theme) + m = m.appendLog("INFO", "Theme reloaded from "+themeSource(msg.path)) + // The dashboard re-renders every frame, but viewport-backed overlays + // hold pre-rendered strings with the old colors baked in as ANSI codes. + if m.state != stateMain { + m = m.resizeOverlay() + } + return m, nil case pingTickMsg: // Ping the highlighted online node, and also the active exit node (so the From c5cfd7a8f0f40ea372e2b696bb6f48d973a080a1 Mon Sep 17 00:00:00 2001 From: Phundahl <89451493+Phundahl@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:02:05 +0200 Subject: [PATCH 2/2] docs(theme): stop the template's own comments being substituted Omarchy seds the whole file, comments included, so instructional text written with {{ placeholders }} got replaced too. The guidance in a generated tailtui.toml then read "use #a2734b here instead", which does not tell anyone which placeholder to write. Name the slots in prose instead. The one {{ placeholder }} left in a comment is deliberate: no theme defines a key by that name, so it renders literally and demonstrates the syntax. Co-Authored-By: Claude Opus 5 (1M context) --- contrib/tailtui.toml.tpl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/contrib/tailtui.toml.tpl b/contrib/tailtui.toml.tpl index a32c50a..b276b9e 100644 --- a/contrib/tailtui.toml.tpl +++ b/contrib/tailtui.toml.tpl @@ -17,8 +17,9 @@ # Available names include the semantic slots (accent, background, foreground, # selection, muted, red, green, yellow, orange, blue, cyan, magenta, and the # bright_/dark_/light_ variants), the legacy terminal palette (color0-color15), -# and `mode`. Modifiers: {{ x_strip }} drops the leading #, {{ x_rgb }} gives -# decimal "r,g,b", and {{ mix a b 30% }} blends two colors. +# and `mode`. A _strip suffix on a name drops the leading #, _rgb gives it +# as decimal "r,g,b", and a mix function blends two colors. The bundled +# alacritty.toml.tpl.sample in this directory documents their exact syntax. # # Run `omarchy-theme-color --file ~/.local/state/omarchy/current/theme/colors.toml --all` # to print every name your current theme defines. @@ -37,7 +38,7 @@ secondary = "{{ green }}" background = "{{ background }}" # Elevated panels and modals — one step away from the canvas. On a light theme -# swap this for {{ dark_background }} so panels shade downward instead of up. +# use the dark_background slot instead, so panels shade downward, not up. surface = "{{ lighter_background }}" # The selected row's highlight bar. @@ -57,7 +58,7 @@ text_dim = "{{ muted }}" # `yellow` is the obvious choice, but some themes define it as something that # collides with `green` — osaka-jade sets yellow to #459451, which would make # exit-node markers nearly indistinguishable from the online color. If that -# happens in your theme, use {{ orange }} here instead. This is exactly the +# happens in your theme, use the orange slot here instead. This is exactly the # judgement call a template exists to hand back to you. warning = "{{ yellow }}"