Skip to content

Add a light-terminal palette, with auto-detection and an override - #3

Open
saforem2 wants to merge 2 commits into
B33pBeeps:mainfrom
saforem2:light-theme
Open

Add a light-terminal palette, with auto-detection and an override#3
saforem2 wants to merge 2 commits into
B33pBeeps:mainfrom
saforem2:light-theme

Conversation

@saforem2

@saforem2 saforem2 commented Aug 14, 2026

Copy link
Copy Markdown

The problem

render.go is deliberately foreground-only — it never sets a background
color, so the terminal's own background shows through. That's what makes
redthread sit so nicely in a tmux pane, but it also means every color in
the palette is implicitly a claim about what that background looks like.

Every one of them assumed dark:

  • note ink around 0.85 Rec-601 luma (blue.Ink = {190,220,248})
  • highlight borders named warm white, cool white, bright ({255,255,255})
  • cork in honey/rust tones, tuned to glow on black
  • Flash, DimText, Footer all bright

On a light terminal the notes wash out to near-unreadable. There was no
light/dark detection anywhere in the codebase, and the background picker
offered ten dark fills and no light ones.

before and after on a light terminal

The change

The colors move out of theme.go into a Palette value in a new
palette.go. darkPalette() reproduces today's colors byte-for-byte —
TestDarkPaletteIsUnchanged pins every one of them, so existing users see
no difference at all. lightPalette() is its counterpart, and
ApplyTheme(light bool) installs one into the package-level vars the draw
sites already read. No call site changed.

the dark theme, unchanged

The light palette is not a luma flip

Two different rules, because two different jobs:

  • Text (tint ink/paper, borders, chrome) inverts: ink becomes the
    darkest and most saturated value of its hue, so a "blue" note still
    reads as blue.
  • Texture (cork specks, shadow dither) is placed at the same
    perceptual distance from a pale background as its dark counterpart
    sits from black — hue preserved, chroma eased ~8%.

I got this wrong on the first pass by applying "make it dark enough" to
everything. The result rendered, and passed a naive luma < 0.5 test, but
looked bad: the cork read as noise and the drop shadows punched grey holes
in the page. Dark cork sits 0.16–0.61 from its background; my first light
cork sat 0.39–0.63 — uniformly too far. theme_test.go now enforces the
two rules separately, and TestLightTextureMirrorsDarkContrastSteps is
the one that would have caught it.

Selecting a theme

--theme=light|dark|auto    # default auto
RT_THEME=light             # for a shell rc or tmux config
T                          # toggles in-app, and is remembered
"theme" in notes.json      # persists the choice

Precedence: flag → env → saved → detection.

Auto asks the terminal for its background via
lipgloss.HasDarkBackground() (already an indirect dep), before
tea.NewProgram takes the screen. When the query goes unanswered — tmux
without allow-passthrough, some ssh sessions, a non-TTY — it reports
dark, which is precisely today's behavior. The override exists for exactly
that case.

Compatibility

  • Schema v5 → v6, adding one optional theme key. v5 files lack it and
    load as auto; there's a test for that. The bump is forward-only — an
    older binary reading a v6 file just ignores the key.
  • Both palettes expose the same nine tint names and the same nine
    SelBorderChoices in the same order, since a board's saved
    highlightColor is an index into that slice. A test asserts the names
    match pairwise.

Also

Five light fills in the background picker (paper, github light, solarized
light, latte, gruvbox light), marked with a faint · so the two groups
read apart. T light/dark is in the help panel. README documents all of it.

the background picker with light fills

Testing

go test ./..., go test -race ./..., go vet ./..., gofmt -l . all
clean. 5 new test files, ~650 lines, covering palette shape, both contrast
rules, flag/env precedence, persistence round-trip, v5 load, and menu
layout at four terminal sizes.

I verified the rendering by dumping View() to ANSI and converting to
HTML, checked at #e8e8e8 and pure white, for the board, both menus, and
the help panel. The screenshots above come from that same dump, so they are
the renderer's real output rather than a mockup. They live on an assets
branch of my fork, not in this diff.

One thing I noticed but did not touch

The background picker's panel doesn't blank the cork behind its interior —
specks bleed through the rows. This is pre-existing and identical in the
dark theme (BackgroundMenuRect computes a width the swatch column
overhangs), so I left it alone rather than mix an unrelated fix into a
color change. Happy to send it separately if you'd like.

The renderer sets only foreground colors and lets the terminal background
show through (render.go). Every color therefore encodes an assumption
about that background — and every one of them assumed it was dark: note
ink sat around 0.85 luma, the highlight borders were literally named
"warm white" / "bright", and the cork was honey over black. On a light
terminal the notes wash out to near-unreadable.

Split the colors out of theme.go into a Palette value in palette.go, with
darkPalette() reproducing today's colors byte-for-byte and lightPalette()
as its counterpart. ApplyTheme() installs one into the package-level vars
the draw sites already read, so no call site changed.

The light palette is not a luma flip. Text inverts (ink becomes the
darkest, most saturated value of its hue) but texture — cork specks,
shadow dither — is placed at the same perceptual *distance* from a pale
background as its dark counterpart sits from black, hue preserved and
chroma eased ~8%. Deriving the texture by "make it dark enough" instead
was visibly wrong: the board read as noise, with the drop shadows
punching holes in the page. theme_test.go now enforces both rules
separately.

Selection:

  --theme=light|dark|auto  (default auto)
  RT_THEME=light           for shell rc / tmux config
  T                        toggles in-app, and is remembered
  "theme" in notes.json    persists the choice (schema v5 -> v6)

Auto queries the terminal background via lipgloss before Bubble Tea takes
the screen. When that query goes unanswered — tmux without passthrough,
some ssh sessions, a non-TTY — it reports dark, which is exactly today's
behavior, and the override is there for when it guesses wrong.

Both palettes expose the same nine tint names and the same nine
SelBorderChoices in the same order, so a saved tint or highlightColor
index means the same thing in either theme.

Also adds five light fills to the background picker (paper, github light,
solarized light, latte, gruvbox light), which previously offered only
dark ones.
Copilot AI lite review requested due to automatic review settings August 14, 2026 14:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces light/dark terminal-aware color palettes for the app’s foreground-only renderer, including auto-detection of terminal background plus user overrides (flag/env/in-app toggle) and persistence in the workspace save file.

Changes:

  • Moves color definitions into a new Palette model (darkPalette unchanged; adds lightPalette) and applies it via ApplyTheme(light bool) without changing draw call sites.
  • Adds theme selection logic: --theme, RT_THEME, saved workspace preference, and OSC 11 background detection; plus an in-app T toggle.
  • Bumps workspace schema to v6 to persist an optional "theme" key; expands background fill picker with light fills and updates docs/tests accordingly.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
README.md Documents light/dark theme behavior, precedence, persistence, and updated schema + picker options.
internal/app/theme.go Converts palette constants to package-level vars populated by ApplyTheme; background fill list gains light entries.
internal/app/palette.go Adds Palette, dark/light palette data, and ApplyTheme to swap package-level colors.
internal/app/theme_mode.go Adds ThemeMode parsing and terminal-background-based resolution for auto mode.
internal/app/storage.go Bumps schema to v6 and persists/loads the workspace theme preference.
internal/app/notes.go Adds Workspace.Theme plus helpers to parse/set/toggle the theme and apply palettes live.
internal/app/model.go Adds T keybinding to toggle theme, re-apply borders, and toast the current mode.
internal/app/menu.go Marks light background fills with a faint · indicator in the picker.
internal/app/theme_test.go Adds contract tests for palette shape, contrast rules, and ApplyTheme behavior.
internal/app/theme_mode_test.go Tests parsing, precedence helpers, and probe behavior.
internal/app/theme_persist_test.go Tests persistence, v5 load behavior, and toggle/apply integration.
internal/app/menu_test.go Ensures picker layout fits common terminals and validates background fills.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/app/app.go Outdated
Comment on lines +45 to +55
mode := themeModeFrom(themeFlag, os.Getenv("RT_THEME"))
if mode == ThemeAuto {
mode = ws.ThemeMode()
}
light := ResolveTheme(mode)
ApplyTheme(light)

// Remember an explicit choice so the next run does not have to probe.
if mode != ThemeAuto {
ws.SetThemeMode(mode)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed and fixed in df1c960.

The root cause was that themeModeFrom returned a bare ThemeMode, so "the user typed auto" and "the user said nothing" were the same value, and Run() read both as "no override". It now returns an explicit-ness bool alongside the mode:

  • only an absent flag and env var defer to the saved theme
  • an explicit auto re-detects and clears the saved value, so the next launch detects too rather than reverting
  • a malformed --theme falls through to RT_THEME instead of reading as a request for auto, and its warning now says "ignoring it" rather than the misleading "using auto"

Covered by TestExplicitAutoOverridesASavedTheme and TestExplicitAutoClearsTheSavedTheme, plus five new cases in the themeModeFrom table. README updated to say that --theme=auto is the way back to detection after pressing T.

themeModeFrom collapsed "the user typed auto" and "the user said nothing"
into the same ThemeAuto value, and Run() treated ThemeAuto as "no
override" and substituted the saved workspace theme. So `--theme=auto`
could not undo a choice made with `T` — the very thing it reads like it
should do — and the warning for a malformed --theme claimed "using auto"
while the saved theme actually won.

Return an explicit-ness bool alongside the mode. Only an absent flag and
env var defer to the saved value; an explicit auto now re-detects and
clears the saved choice, so the next launch detects too. A malformed flag
falls through to the env var rather than reading as a request for auto.

Reported by Copilot review on B33pBeeps#3.
@saforem2

Copy link
Copy Markdown
Author

Whenever you have time — no rush.

Quick summary in case it helps triage: this is the light-terminal palette. The dark palette is byte-identical to what ships today (there is a test pinning all 19 chrome colors and the tints), so existing users see no change at all. Detection falls back to dark when the terminal swallows the OSC 11 query, which is also current behavior, and --theme / RT_THEME / T are there for when it guesses wrong.

The one thing that would benefit from your eye is the light color values themselves — they are my judgment calls, and you know how the board should feel better than I do. Happy to adjust any of them, or to drop the background-picker additions if you would rather keep that list short.

Also glad to rebase or split this differently if that makes it easier to review.

@saforem2 saforem2 mentioned this pull request Aug 21, 2026
@B33pBeeps

Copy link
Copy Markdown
Owner

Thank you for the PR's, I will review all of them and hopefully have them merged in shortly. I appreciate the bugs found and the methodology of the fixes, and will do a review run of all the pending PR's I currently have. The structure is clear and no other questions!

@saforem2

Copy link
Copy Markdown
Author

Thanks for the update, and no rush at all — take them in whatever order suits you.

One note that may help sequencing: #3, #4, and #6 are independent, but #5 ($EDITOR) has a small interaction with #4 (undo). I have left the details on #5. Short version: the external-edit path needs three lines to hook into #4's undo seam, and that change cannot compile until #4 is in. So if you merge #5 before #4, it is worth a follow-up; the other order needs nothing.

I combined all four locally to check they compose — they do, and that interaction was the only real issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants