Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **The theme skill knows `formatVersion`.** lich themes gained an optional
`formatVersion` (omitted means 1), and a lich refuses a format newer than it
reads by that number. `validate.mjs` checks it with the same bounds and the
same wording, and the skill's rules list carries it.
- **Theme repositories can name the lich they need.** The pack manifest gained
an optional `minLichVersion`, which an older lich refuses by version, and an
optional `formatVersion` under the same rule as a theme file. The skill
suggests `minLichVersion` for a pack that uses a token only a recent lich has,
and `validate.mjs` checks the shape of both. Needs omartelo/lich#600.
- **A broken stored theme is no longer described as invisible.** lich now lists
a stored theme it cannot load under the theme strip in Settings › Appearance,
with the reason and a Remove action, so the skill's trap about files copied
into the themes directory says so instead of pointing at the log. Needs
omartelo/lich#598.

## [0.12.0] - 2026-09-02

### Changed
Expand Down
27 changes: 22 additions & 5 deletions skills/theme/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: theme
description: Write, port, or fix a color theme for the lich harness — the JSON that recolors lich's interface tokens and its xterm terminal palette, as a single file or as a versioned theme repository. Use when asked to build a lich theme, port a palette (Tokyo Night, Gruvbox, Catppuccin, Nord…) into lich, start or lay out a theme repository, publish or version a set of themes, ship an update to a theme somebody already installed, tweak the colors of an installed theme, or when a custom theme is rejected on import or silently missing from Settings › Appearance.
description: Write, port, or fix a color theme for the lich harness — the JSON that recolors lich's interface tokens and its xterm terminal palette, as a single file or as a versioned theme repository. Use when asked to build a lich theme, port a palette (Tokyo Night, Gruvbox, Catppuccin, Nord…) into lich, start or lay out a theme repository, publish or version a set of themes, ship an update to a theme somebody already installed, tweak the colors of an installed theme, or when a custom theme is rejected on import, listed in Settings › Appearance as a theme that can't load, or missing from it.
---

# lich themes
Expand Down Expand Up @@ -112,6 +112,11 @@ manifest**, commit, push. The user takes it with **Update** on the theme's row.

## Validation rules (the backend rejects anything else)

- `formatVersion`: optional, the theme file format. Omitted means `1`, the only
format so far, so leave it out. When present it is a positive integer no
greater than the format this lich reads (currently `1`); a newer one is
refused by that number ("theme format 2 is newer than this lich reads (up to
1); update lich"). lich writes it into every theme it stores.
- `id`: required, matches `^[a-z0-9][a-z0-9._-]{0,63}$`. Not `light`, `dark`,
`system` or `match`; not a Windows device name (`con`, `prn`, `aux`, `nul`,
`com1`–`com9`, `lpt1`–`lpt9`) — the id names a file.
Expand All @@ -125,6 +130,15 @@ manifest**, commit, push. The user takes it with **Update** on the theme's row.
`name` and a `MAJOR.MINOR.PATCH` `version` — a pre-release like `1.0.0-rc1` is
rejected, because the update check cannot order two of them. At least one and
at most 32 themes beside it, each with a distinct `id`.
- Repository only, optional in the manifest:
- `minLichVersion`: the oldest lich release the pack is written for, as
`MAJOR.MINOR.PATCH`. An older lich refuses the install or update ("theme
pack needs lich X or newer (this is Y); update lich") and leaves what is
installed alone; a dev build, or one between release tags, skips the check.
Set it when a theme uses a token only a recent lich has, so an older lich
names the version it needs instead of an unknown token.
- `formatVersion`: the same rule as in a theme file; a newer one is refused
with the error prefixed `manifest: `.
- Color values: non-blank, ≤ 128 characters.
- `app` accepts hex (`#rgb` … `#rrggbbaa`), a CSS color name, or
`rgb()`/`rgba()`/`hsl()`/`hsla()`/`oklch()`/`oklab()`/`lab()`/`lch()`/`color()`.
Expand All @@ -137,10 +151,13 @@ manifest**, commit, push. The user takes it with **Update** on the theme's row.

- **Copying a file into the themes directory is not installing it.** The theme
list is read once, at page load, so nothing appears until the window is
reloaded — the user is told the theme is in and cannot find it in Settings.
And a file that fails a rule, or is named anything but `<id>.json`, is skipped
with a warning in the log and nothing on screen. Import is the only path that
ends with the theme selected and the errors spelled out.
reloaded, so the user is told the theme is in and cannot find it in Settings.
After the reload, a file that fails a rule, is named anything but its own
`<id>.json`, or declares a newer `formatVersion` shows up under the theme strip
as a theme that can't load, with the reason and a Remove action, never as a
theme to pick. A file whose name is not a valid theme id is only logged. Import
is the only path that ends with the theme selected and the errors spelled out
before anything is stored.
- **A non-hex terminal color half-applies.** xterm parses hex directly;
everything else goes through a round-trip that throws on translucency and is
swallowed into a fallback. The backend rejects it up front — don't work around
Expand Down
22 changes: 22 additions & 0 deletions skills/theme/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const WINDOWS_DEVICES = [
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
]
// The newest theme file format lich reads; FormatVersion in internal/themes.
const FORMAT_VERSION = 1
const MANIFEST = "lich-theme.json"
const MAX_THEMES = 32

Expand All @@ -37,8 +39,20 @@ if (!target) {
process.exit(2)
}

function formatVersionError(document) {
if (!("formatVersion" in document)) return null
const version = document.formatVersion
if (!Number.isInteger(version) || version < 1)
return `theme formatVersion ${JSON.stringify(version)} must be a positive integer`
if (version > FORMAT_VERSION)
return `theme format ${version} is newer than this lich reads (up to ${FORMAT_VERSION}); update lich`
return null
}

function themeErrors(theme) {
const errors = []
const format = formatVersionError(theme)
if (format) errors.push(format)
if (!ID.test(theme.id ?? "")) errors.push(`id ${JSON.stringify(theme.id)} must match ${ID}`)
if (RESERVED.includes(theme.id)) errors.push(`id "${theme.id}" is bundled or reserved`)
if (WINDOWS_DEVICES.includes(theme.id)) errors.push(`id "${theme.id}" is a Windows device name`)
Expand Down Expand Up @@ -85,13 +99,21 @@ function checkRepository(dir) {
const manifest = read(join(dir, MANIFEST))
if (manifest.error) return [`${manifest.error} — every theme repository needs one at its root`]
const errors = []
const format = formatVersionError(manifest.value)
if (format) errors.push(`${MANIFEST}: manifest: ${format}`)
if (!manifest.value.name?.trim()) errors.push(`${MANIFEST}: name is required`)
if ([...(manifest.value.name ?? "")].length > 128)
errors.push(`${MANIFEST}: name cannot exceed 128 characters`)
if (!RELEASE.test(manifest.value.version ?? ""))
errors.push(
`${MANIFEST}: version ${JSON.stringify(manifest.value.version)} must be MAJOR.MINOR.PATCH`,
)
// Only the shape: whether the running lich is new enough is lich's call.
const minimum = manifest.value.minLichVersion
if (minimum !== undefined && minimum !== "" && !RELEASE.test(minimum))
errors.push(
`${MANIFEST}: manifest minLichVersion ${JSON.stringify(minimum)} must be MAJOR.MINOR.PATCH`,
)

const files = readdirSync(dir)
.filter((name) => name !== MANIFEST && extname(name) === ".json")
Expand Down
Loading