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..b276b9e --- /dev/null +++ b/contrib/tailtui.toml.tpl @@ -0,0 +1,66 @@ +# 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`. 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. + +# "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 +# use the dark_background slot instead, so panels shade downward, not 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 the orange slot 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