Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .codespellrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
[codespell]
# The pre-commit hook passes these on the command line. Naming them here is what
# makes a bare `codespell` at the repo root agree with what the gate enforces —
# without it, a developer checking their own work gets a pass on the very files
# the hook rejects.
builtin = clear,rare,en-GB_to_en-US

# noice = the folke/noice.nvim plugin (highlight-group name), not a typo
ignore-words-list = noice

# `align=centre` is tmux's spelling and the only one its style parser accepts:
# `align=center` is refused as "invalid style", the same error a nonsense value
# gets. Every align= site in this repo is inside a *format* rather than a style,
# and a format validates nothing — `#[align=center]` and `#[align=bogusvalue]`
# both set at exit 0 and simply draw left. The wrong spelling fails silently, so
# the literal is exempted here rather than the word. The word in prose is still
# a finding.
#
# The second alternation is a bash associative-array subscript. The only one
# holding British words is `SETTLED` in scripts/check-spelling.sh, where each
# subscript is a regex naming the spelling it settles — a citation rather than a
# use, and unwritable any other way. Scoping to the subscript keeps that script's
# own prose gated, which skipping the file would not.
ignore-regex = align=centre|\["[^"]*"\]
27 changes: 25 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# forge-toolchain: 20
# forge-toolchain: 21
fail_fast: true
default_stages: [pre-commit]
repos:
Expand Down Expand Up @@ -106,6 +106,22 @@ repos:
# two-letter sequence codespell corrects. Nobody writes prose in them and
# nobody would fix a hit, since the next install would put it back.
- id: codespell
# `clear,rare` is codespell's own default and is restated because naming
# any dictionary replaces the list rather than adding to it. The third
# makes American spelling a gate instead of a habit — the -ise, -our and
# -re families become findings at the moment they are typed.
#
# No example is spelled out here, for the reason the go.mod note below
# gives: an example would trip this hook on this very file, in every repo
# this block is generated into.
#
# `-H` because codespell skips any file whose *basename* starts with a
# dot and exits 0 rather than saying it read nothing. Without it this
# very file sits outside the check it installs, along with every other
# dot-named tracked file. A dot-named *directory* was always read, so
# `.github/**` never needed it. pre-commit passes tracked paths, so the
# flag widens what is read rather than reaching untracked files.
args: ['-H', '--builtin', 'clear,rare,en-GB_to_en-US']
# The second alternation is sops output. Ciphertext is base64, so it
# produces the same false hits a lock file's hashes do. The plain
# `secrets.yaml` name is covered alongside the *.enc.env convention.
Expand All @@ -116,7 +132,14 @@ repos:
# module name codespell wants spelled differently, and no edit fixes it —
# the next `go mod tidy` writes it straight back. Naming such a module
# here would trip this hook on this very file.
exclude: '(^|/)(.*-lock\.json|.*\.lock|uv\.lock|package-lock\.json|go\.sum|go\.mod|secrets?[^/]*\.(ya?ml|env|json)|.*\.enc\.[^/]+)$'
#
# A delimited data file is values rather than prose, and a British place
# or company name in one is the record. Respelling it corrupts the data,
# so the whole file class is out of scope the way a lock file is — one
# repo's datasets carry 3,575 such values across thirteen files, and not
# one of them is a misspelling. `.tsv` is named beside `.csv` as the same
# format with a different delimiter.
exclude: '(^|/)(.*-lock\.json|.*\.lock|uv\.lock|package-lock\.json|go\.sum|go\.mod|secrets?[^/]*\.(ya?ml|env|json)|.*\.enc\.[^/]+|.*\.[ct]sv)$'

# generated:refcheck - Reference checking
#
Expand Down
68 changes: 51 additions & 17 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ says which theme it is running, every theme applied stays installed beside the
others, and an exported config is self-describing.

Nothing is pruned, deliberately: these are also the directories a user's own themes
live in, and an installer that deletes what it does not recognise is worse than a
live in, and an installer that deletes what it does not recognize is worse than a
few stale kilobytes.

Four apps have no pointer at all and are not part of this — dunst reads
Expand Down Expand Up @@ -209,7 +209,7 @@ Run from the repo root (`~/tools/theme`). Writing `theme.yml` is the whole job;
every app config is derived from it.

1. **Write `themes/{id}/theme.yml`** — meta, base16, ansi, special, extended.
Mapping a palette into those slots is the only part that takes judgement:
Mapping a palette into those slots is the only part that takes judgment:
see "Mapping a Palette into theme.yml" below.

2. **Generate every app config in one call.** Never invoke generators one by one
Expand All @@ -228,7 +228,7 @@ every app config is derived from it.

3. **Verify against the source, not by eye.** When a theme comes from a plugin
that ships its own terminal configs, diff the generated file against theirs —
an exact match on every colour is the proof the transcription is right:
an exact match on every color is the proof the transcription is right:

```bash
diff <(rg -N "^(background|foreground|cursor|selection|palette)" themes/{id}/ghostty.conf) \
Expand All @@ -244,14 +244,14 @@ every app config is derived from it.

### Mapping a Palette into theme.yml

Upstream palettes are organised by *role* ("keywords", "types"); base16 slots are
organised by *hue*. When they conflict, **follow hue** — every theme here does,
Upstream palettes are organized by *role* ("keywords", "types"); base16 slots are
organized by *hue*. When they conflict, **follow hue** — every theme here does,
and the generators mix `base16` and `ansi` values in the same output file, so a
role-based mapping puts a green in the slot a generator draws its red from.

- `base0D` is the single most-used slot (`rg -c BASE0D lib/generators/*.sh`), and
generators treat it as the primary UI accent, not as "the blue". If a theme's
true blue is a loud diagnostic colour, set `base0D` to the restrained blue and
true blue is a loud diagnostic color, set `base0D` to the restrained blue and
put the intended accent in `extended.ui_accent`, which overrides `BASE0D` in
every generator that draws UI chrome.
- `base06`/`base07` are "lighter/lightest foreground". Duplicating `base05` when
Expand All @@ -263,6 +263,14 @@ role-based mapping puts a green in the slot a generator draws its red from.
rg -o --no-filename "EXTENDED_[A-Z0-9_]+" lib/ | sort -u
```

- **Spell American, except where you are quoting upstream's own slot name.** The
repo spells identifiers and prose American and the codespell hook gates it, so
the British form in a key or a sentence is a finding. A comment naming the
upstream slot a value came from is a citation and keeps upstream's spelling —
everforest calls its slots `grey1` and `grey2`, and `gray1` would name nothing.
`scripts/check-spelling.sh` is what tells the two apart, because neither the
hook nor a `\b`-anchored search can see a digit-suffixed token like `grey1`.

### The Generated Neovim Colorscheme

Every theme has one, and `generate-all.sh` produces it like any other artifact —
Expand All @@ -272,7 +280,7 @@ directory, neither of which the job runner cares about: it passes the mapped
value through as `$2` and nothing else reads it. The PEP 723 header is what makes
it directly executable, so it needs no `uv run` wrapper at the call site.

**The name it emits depends on where the theme's colours come from**, and this is
**The name it emits depends on where the theme's colors come from**, and this is
the constraint to preserve:

| `neovim_colorscheme_source` | Emits | Why |
Expand Down Expand Up @@ -339,7 +347,7 @@ theme costs 0.25s.
moved on, not as noise.
- **A generated colorscheme is a fallback, not a substitute**: it is derived from
the palette, where the plugin was tuned by hand against real buffers. It exists
so a machine without the plugin still changes colour, and `theme apply` says so
so a machine without the plugin still changes color, and `theme apply` says so
by name when it is what Neovim will land on.
- **Generate only through `generate-all.sh`**: a one-off generator invocation is
how `mako.ini` came back a week after the migration that deleted it — eight
Expand All @@ -362,7 +370,7 @@ theme costs 0.25s.
nobody can see is worse than none, because a quiet run reads as a correct one.
- **The one failure with no output is Neovim.** A plugin theme names a colorscheme
this tool does not ship, so a machine that never installed it gets a terminal
that changes colour and an editor that does not — while every tick still says
that changes color and an editor that does not — while every tick still says
the apply worked. `check_neovim_colorscheme` looks for the
`colors/<name>.{lua,vim}` that `:colorscheme` actually needs, deliberately not
asking lazy.nvim where it puts plugins, so the `vim.pack` migration cannot turn
Expand Down Expand Up @@ -447,10 +455,10 @@ runtime. Worth running once or twice a year, and after adding a plugin theme.

Two things it can find, and the second is the common one:

- **Upstream changed a colour** after the transcription.
- **Upstream changed a color** after the transcription.
- **The transcription was wrong from the start.** This was the one real finding:
`nightfox` had a bright-black of `#475072`, a blue-purple, where upstream
computes `#575860`, a grey — `Shade.new("#393b44", 0.15)`.
computes `#575860`, a gray — `Shade.new("#393b44", 0.15)`.

A third outcome the check cannot tell from the second: **deliberate divergence.**
`solarized-osaka` carries *classic* Solarized (Schoonover) rather than craftzdog's
Expand All @@ -465,11 +473,11 @@ It picks its method per theme:

- Repos shipping a terminal config (`extras/ghostty`, `extra/<v>/<v>.ghostty`,
`extras/alacritty/…`) get an **exact** check — that file is generated from the
same palette the colorscheme uses, so comparing colour sets settles it without
parsing Lua. Only colours *we* have and upstream lacks are errors; the reverse
same palette the colorscheme uses, so comparing color sets settles it without
parsing Lua. Only colors *we* have and upstream lacks are errors; the reverse
is normally an extended slot past the 16 ANSI that we do not generate.
- Everything else gets a **history** check: did any added or removed line in
upstream carry a colour since `theme.yml` was last written? That only says
upstream carry a color since `theme.yml` was last written? That only says
"look at this", never "this is wrong".

Two traps it exists to encode:
Expand All @@ -480,11 +488,11 @@ Two traps it exists to encode:
- **A repo can host several colorschemes.** flexoki ships one file per variant,
so a change to one would otherwise flag all four. The check keeps only changed
files naming this theme's variant.
- **Judge by colours changed, never by filename.** flexoki's `colors/*.lua` are
two-line loaders holding no colours, and a repo may keep its palette anywhere.
- **Judge by colors changed, never by filename.** flexoki's `colors/*.lua` are
two-line loaders holding no colors, and a repo may keep its palette anywhere.
Filtering on paths produced three false positives out of five on the first run.
- **A shipped extra can be as stale as our own copy.** kanagawa's
`extras/alacritty` calls terminal black `#090618`, a colour in none of its Lua
`extras/alacritty` calls terminal black `#090618`, a color in none of its Lua
source, while `themes.lua` sets `term[1]` to `sumiInk0` (`#16161d`) — ours was
right. It is out of the exact-check map for that reason. Where a repo ships
several formats that disagree, prefer the freshest and check it against the
Expand All @@ -493,6 +501,31 @@ Two traps it exists to encode:
When it flags something, read the upstream palette and fix `theme.yml`, then
`lib/generate-all.sh --themes <id>`.

## Checking Spelling

`scripts/check-spelling.sh` finds British spellings the commit gate cannot see.
Run it before a release and after transcribing a plugin palette.

**The gate has two blind spots and they overlap.** codespell tokenizes on its
default word regex, which keeps `display-panes-colour` and `grey1` whole — there
is no word boundary between a letter and a digit, so an `rg` pattern anchored on
`\b` misses the same two. codespell also refuses to open a dot-prefixed file
without `-H`, so `.codespellrc` and `.pre-commit-config.yaml` are outside it. The
script widens the token regex to letters only and passes `-H`, which reaches all
three classes in one call.

**Widening it is what makes the settled list necessary.** A British spelling that
is genuinely correct then surfaces on every run, so `SETTLED` declares each one
with its reason and the run reports them as a count rather than as findings. It
holds the five places the spelling deliberately stays: tmux's three `*-colour`
option names, `align=centre`, everforest's `grey1`/`grey2`, hex fragments the
widened regex reads as words, and a backticked citation of a spelling being
discussed. Anything not matching one is printed with its source line and the
script exits non-zero.

`--list-settled` prints the settled findings too, which is how you check a
pattern is still earning its place rather than absorbing something new.

## Tests

`bats tests/` — also run by the pre-commit hook and in CI, both generated by
Expand Down Expand Up @@ -554,6 +587,7 @@ is not deterministic.
| `lib/generators/neovim.py` | Generates the Neovim colorscheme; see The Generated Neovim Colorscheme |
| `install.sh` | Installation script for fresh installs |
| `scripts/check-plugin-drift.sh` | Compares plugin themes against upstream; see Checking Plugin Themes Against Upstream |
| `scripts/check-spelling.sh` | Finds British spellings the pre-commit hook cannot see, and declares the ones that stay; see Checking Spelling |
| `scripts/test-all-themes.sh` | Generates and validates every theme |
| `scripts/test-theme-apps.sh` | Checks each app's generated output |
| `tests/helpers.bash` | Suite isolation, fixture builders, command stubs — see Tests |
Expand Down
6 changes: 3 additions & 3 deletions lib/generators/background-ascii.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ bg_rgb=$(hex_to_rgb "$BASE00")

# -C colors each glyph from the source pixel underneath it, which also makes
# --font-color a no-op — passing both produced byte-identical output. Keep the
# per-glyph colour, since recoloring below maps it into the palette and a single
# flat font colour would throw the image away entirely.
# per-glyph color, since recoloring below maps it into the palette and a single
# flat font color would throw the image away entirely.
# -s takes a directory; the filename is auto-generated as <basename>-ascii-art.png
temp_dir="/tmp/ascii_$$"
mkdir -p "$temp_dir"
Expand All @@ -72,7 +72,7 @@ if [[ ! -f "$generated_file" ]]; then
exit 1
fi

# --save-bg themes the ground but the glyphs keep the photo's colours, so the
# --save-bg themes the ground but the glyphs keep the photo's colors, so the
# result only half-matched the theme. Hand it to the recolor generator, but not
# on the settings lowpoly and recolor use: ASCII output is mostly ground, so it
# arrives dark already (mean ~20 of 100) and the darkening pass crushes it to
Expand Down
6 changes: 3 additions & 3 deletions lib/generators/background-recolor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
#
# The defaults are what recolor and lowpoly both want: the full palette, then a
# darkening pass. A photo's brightness survives recoloring — gowall maps each
# pixel to the nearest palette colour, so a bright sky lands on the theme's
# pixel to the nearest palette color, so a bright sky lands on the theme's
# foreground and stays a pale expanse. Backgrounds are seen through terminal
# transparency, where that washes out.
#
Expand Down Expand Up @@ -106,15 +106,15 @@ palette_name="theme-$theme_id-dark"
# The full palette adds the foreground tier and the lightest accent back. Those
# four are exactly what a bright region maps onto, so the dark variant is this
# same palette with its top end removed rather than a different set of hues.
# Removing more than this pushes bright areas onto a coloured accent and tints
# Removing more than this pushes bright areas onto a colored accent and tints
# the sky - salmon on a warm theme, lavender on a cool one.
if [[ "$palette_variant" == "full" ]]; then
colors+=("$BASE05" "$BASE06" "$BASE07" "$BASE0A")
palette_name="theme-$theme_id"
fi

# Rewritten on every run rather than reused when present. The old config-append
# skipped regeneration once a name existed, so editing a colour in theme.yml
# skipped regeneration once a name existed, so editing a color in theme.yml
# never reached gowall and the wallpaper kept the palette from first use.
palette_file="$palette_dir/${palette_name}.json"
{
Expand Down
10 changes: 5 additions & 5 deletions lib/generators/neovim.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def slug_to_module(slug: str) -> str:
def colorscheme_name_for(meta: dict, slug: str) -> str:
"""Name the colorscheme this theme's generated plugin provides.

A theme whose colours come from a plugin gets a `theme-` prefix, because its
A theme whose colors come from a plugin gets a `theme-` prefix, because its
id is usually the plugin's own colorscheme name — `kanagawa`, `gruvbox` and
ten others — and two `colors/kanagawa.lua` on the runtimepath is a coin toss
over which one `:colorscheme` finds. The prefixed copy is the fallback for a
Expand All @@ -56,7 +56,7 @@ def get_color(extended: dict, extended_key: str, base16_fallback: str) -> str:


def resolve_color(base16: dict, extended: dict, extended_key: str, base16_fallback: str) -> str:
"""Resolve a semantic colour to its hex value rather than a palette reference.
"""Resolve a semantic color to its hex value rather than a palette reference.

`get_color` emits `M.palette.foo` so a highlight file can stay symbolic. A
band has to be computed here instead, which needs the value.
Expand All @@ -73,8 +73,8 @@ def resolve_color(base16: dict, extended: dict, extended_key: str, base16_fallba
DIFF_EMPHASIS_CONTRAST = 2.2


def _channels(colour: str) -> tuple[int, int, int]:
h = colour.lstrip("#")
def _channels(color: str) -> tuple[int, int, int]:
h = color.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


Expand Down Expand Up @@ -269,7 +269,7 @@ def generate_editor_lua() -> str:
Directory = { fg = theme.syn.fun },

-- Diff. No foreground on any of them: a foreground here wins over
-- treesitter and flattens a whole hunk to one shade, which is the colour
-- treesitter and flattens a whole hunk to one shade, which is the color
-- you least want flattened.
DiffAdd = { bg = theme.diff.add },
DiffChange = { bg = theme.diff.change },
Expand Down
Loading
Loading