diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2f447..cee7b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/skills/theme/SKILL.md b/skills/theme/SKILL.md index 1a5d505..be27031 100644 --- a/skills/theme/SKILL.md +++ b/skills/theme/SKILL.md @@ -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 @@ -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. @@ -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()`. @@ -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 `.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 + `.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 diff --git a/skills/theme/validate.mjs b/skills/theme/validate.mjs index a56a9b2..ed6d004 100644 --- a/skills/theme/validate.mjs +++ b/skills/theme/validate.mjs @@ -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 @@ -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`) @@ -85,6 +99,8 @@ 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`) @@ -92,6 +108,12 @@ function checkRepository(dir) { 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")