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

## [Unreleased]

### Added

- **The theme skill writes a theme's `syntax` and `pair`.** It ports a palette's
code colors into the optional `syntax` section, which colors the code in
`lich native`'s review, diff and file views, and pairs the light and dark
variants of a palette so the native window follows the desktop between them.
`validate.mjs` checks both the way lich does: an unknown highlighting tag or
modifier, a style with no color and no font style, a theme that is its own
pair, or a pair of its own scheme.

## [0.20.2] - 2026-10-15

### Fixed
Expand Down
91 changes: 84 additions & 7 deletions skills/theme/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ different engines:
They are picked independently in Settings › Appearance: the terminal picker has
a `Match app theme` default, so one theme can dress both.

Two optional keys go further, read by the native window (`lich native`) only:

- **`syntax`** — colors the code in its review, diff and file views, by
CodeMirror highlighting tag. See [Syntax highlighting](#syntax-highlighting).
- **`pair`** — the id of the theme's variant for the other scheme, which the
window draws while the OS asks for that scheme and Auto light/dark is on. See
[Pairs](#pairs).

`template.json`, next to this file, is the same starter lich itself hands out
(Settings › Appearance › **Save template**). It names every supported color —
start from it, don't type the shape from memory.
Expand Down Expand Up @@ -60,15 +68,18 @@ Same work either way:
2. Fill `app` by **role**, not by hue — see the token table below.
3. Fill `terminal`. `background`/`foreground` are required; give the 16 ANSI
colors too unless you want xterm's built-in palette fighting your background.
4. Validate: `node validate.mjs <file-or-directory>` (in this skill's directory).
4. Fill `syntax` from the source palette's own code colors, and set `pair` when
the palette has a light and a dark variant. Both are optional; drop the
template's `pair` when there is no other variant.
5. Validate: `node validate.mjs <file-or-directory>` (in this skill's directory).
It checks the same rules the backend checks, so a pass means the install will
not bounce.

## Handing over a single file

5. Write it where the user can point a file picker at it — the session's working
6. Write it where the user can point a file picker at it — the session's working
directory, as `<id>.json` — and tell them the absolute path.
6. Hand the install to them: **Settings › Appearance › Import › Choose file**,
7. Hand the install to them: **Settings › Appearance › Import › Choose file**,
then pick that file. lich validates it, stores it under the name it wants,
applies it and selects it on the spot — no window reload. The file from step 5
is only the hand-off copy; say it can be deleted once the theme is in.
Expand All @@ -85,7 +96,7 @@ my-lich-themes/
└── gruvbox.json
```

5. Scaffold it in the user's working directory (or the repository they name):
6. Scaffold it in the user's working directory (or the repository they name):

```bash
mkdir -p my-lich-themes && cd my-lich-themes
Expand All @@ -98,11 +109,11 @@ my-lich-themes/
EOF
```

6. Write each theme beside the manifest. File names are yours — lich stores each
7. Write each theme beside the manifest. File names are yours — lich stores each
one as `<id>.json` in its own directory — so name them after the theme.
7. `node validate.mjs my-lich-themes/` checks the manifest and every theme at
8. `node validate.mjs my-lich-themes/` checks the manifest and every theme at
once, the same way an install reads them. Commit only once it passes.
8. Hand the install to them: **Settings › Appearance › Import**, paste the
9. Hand the install to them: **Settings › Appearance › Import**, paste the
**absolute path** of the directory, **Install**. An absolute path is an
accepted remote, so this works before the repository is pushed anywhere. Once
it is on a host, the clone URL is what everyone else pastes.
Expand Down Expand Up @@ -145,6 +156,17 @@ manifest**, commit, push. The user takes it with **Update** on the theme's row.
`var()`, `color-mix()`, `url()`, `attr()`, `image-set()` are rejected —
a theme may not name a resource or defer a value.
- `terminal` accepts **hex only**. See the traps.
- `syntax`: optional, an object. Each key is a highlighting tag
(`keyword`, `comment`, `variableName`…) or a modifier wrapping one
(`definition`, `constant`, `function`, `standard`, `local`, `special`, as in
`function(variableName)`); an unknown tag or modifier is an error. Each value
is a color, accepted as an `app` color is, or an object with `color` and any
of `bold`, `italic`, `underline`, `strikethrough` set to `true`. An object
with neither a color nor a font style is an error.
- `pair`: optional, a theme id, not the theme's own. lich lists a theme whose
pair is installed with **the same** scheme as a theme that can't load,
bundled `light` and `dark` included. A pair nobody installed is fine: the
theme loads, with nothing to follow to.
- The file is read only if it is a regular file ≤ 1 MB.

## Traps
Expand Down Expand Up @@ -184,6 +206,58 @@ manifest**, commit, push. The user takes it with **Update** on the theme's row.
terminal background.
- Reimporting an id that already exists asks before replacing it.

## Syntax highlighting

`syntax` styles code by [CodeMirror highlighting tag](https://lezer.codemirror.net/docs/ref/#highlight.tags),
and resolves the way CodeMirror's `HighlightStyle` does: code takes the style of
the most specific tag that has one, so `lineComment` falls back to `comment` and
`definition(propertyName)` to `propertyName`; code no style reaches is drawn in
`app.foreground`. Style the broad tags first (`keyword`, `string`, `comment`,
`number`, `typeName`, `variableName`, `propertyName`, `function(variableName)`,
`operator`, `punctuation`), then the specific ones a port wants apart.

```json
"syntax": {
"keyword": "#c4a7e7",
"string": "#f6c177",
"comment": { "color": "#6e6a86", "italic": true },
"function(variableName)": "#ebbcba",
"constant(variableName)": { "color": "#83c092", "bold": true },
"annotation": "#e39b7b",
"heading": { "bold": true }
}
```

Port it from the source's own editor theme, not by guessing from the ANSI
colors: a JetBrains color scheme XML or a VS Code theme's `tokenColors` names
what each kind of token gets. lich tags Java's enum constants
`constant(variableName)`, its annotations `annotation`, its field declarations
`definition(propertyName)` and its import paths `namespace`, which is what an
IDE's constant, metadata, field and package colors map to.

A lich that predates `syntax` ignores the key, so a pack adds it without a
`minLichVersion`.

## Pairs

A palette with a light and a dark variant pairs them, so the native window can
follow the desktop: with Auto light/dark on, it draws the pair while the OS asks
for the pair's scheme, and goes back when it stops. The theme the user chose
stays the one in use.

```json
{ "id": "forest-night", "scheme": "dark", "pair": "forest-day", ... }
{ "id": "forest-day", "scheme": "light", "pair": "forest-night", ... }
```

A pair need not point back. Three dark variants and one light one pair each
dark with the light, and the light with the variant most people pick: a user on
any dark variant comes back to it, since only the chosen theme is ever followed
from. A single-variant palette leaves `pair` out.

A lich that predates `pair` ignores the key, so a pack adds it without a
`minLichVersion`.

## App tokens by role

Surfaces (each `*-foreground` is the text drawn **on** that surface — keep the
Expand Down Expand Up @@ -230,6 +304,9 @@ Claude Code's own meta lines vanish.
- [ ] `border` reads as a hairline, not a rule
- [ ] `emerald-500` / `amber-500` / `destructive` still read on your surfaces
- [ ] Terminal `background` sits well beside app `background` (they touch)
- [ ] `syntax` ported from the source's editor theme, the broad tags styled
- [ ] `pair` set on every variant of a light-and-dark palette, and on no
single-variant one
- [ ] Repository: `version` bumped in the same commit as a theme change
- [ ] Handed over as a path plus the Settings › Appearance › Import step —
never written into the themes directory
46 changes: 46 additions & 0 deletions skills/theme/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"id": "purple-night",
"name": "Purple Night",
"scheme": "dark",
"pair": "purple-day",
"app": {
"background": "#15111f",
"foreground": "#f4efff",
Expand Down Expand Up @@ -61,5 +62,50 @@
"brightMagenta": "#d5b7ff",
"brightCyan": "#a0fff1",
"brightWhite": "#ffffff"
},
"syntax": {
"keyword": "#c396f5",
"string": "#82cb92",
"regexp": "#82cb92",
"escape": "#82cb92",
"special(string)": "#82cb92",
"comment": {
"color": "#9f9fa9",
"italic": true
},
"processingInstruction": {
"color": "#9f9fa9",
"italic": true
},
"literal": "#eca760",
"number": "#eca760",
"bool": "#eca760",
"null": "#eca760",
"atom": "#eca760",
"typeName": "#71d0d5",
"className": "#71d0d5",
"namespace": "#71d0d5",
"propertyName": "#82b1ed",
"tagName": "#82b1ed",
"function(variableName)": "#82b1ed",
"punctuation": "#9f9fa9",
"operator": "#9f9fa9",
"operatorKeyword": "#9f9fa9",
"invalid": "#ff6467",
"strong": {
"bold": true
},
"heading": {
"bold": true
},
"emphasis": {
"italic": true
},
"link": {
"underline": true
},
"strikethrough": {
"strikethrough": true
}
}
}
81 changes: 81 additions & 0 deletions skills/theme/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ const WINDOWS_DEVICES = [
]
// The newest theme file format lich reads; FormatVersion in internal/themes.
const FORMAT_VERSION = 1
// The highlighting tags a syntax key may name, @lezer/highlight's vocabulary as
// godemirror's tags.Parse reads it, and the modifiers that wrap one.
const TAG_NAMES = new Set([
"angleBracket", "annotation", "arithmeticOperator", "atom", "attributeName",
"attributeValue", "bitwiseOperator", "blockComment", "bool", "brace", "bracket",
"changed", "character", "className", "color", "comment", "compareOperator", "content",
"contentSeparator", "controlKeyword", "controlOperator", "definitionKeyword",
"definitionOperator", "deleted", "derefOperator", "docComment", "docString",
"documentMeta", "emphasis", "escape", "float", "heading", "heading1", "heading2",
"heading3", "heading4", "heading5", "heading6", "inserted", "integer", "invalid",
"keyword", "labelName", "lineComment", "link", "list", "literal", "logicOperator",
"macroName", "meta", "modifier", "moduleKeyword", "monospace", "name", "namespace",
"null", "number", "operator", "operatorKeyword", "paren", "processingInstruction",
"propertyName", "punctuation", "quote", "regexp", "self", "separator", "squareBracket",
"strikethrough", "string", "strong", "tagName", "typeName", "typeOperator", "unit",
"updateOperator", "url", "variableName",
])
const TAG_MODIFIERS = ["definition", "constant", "function", "standard", "local", "special"]
const FONT_STYLES = ["bold", "italic", "underline", "strikethrough"]
// The bundled themes' schemes, which a custom theme may name as its pair.
const BUNDLED_SCHEMES = { light: "light", dark: "dark" }
const MANIFEST = "lich-theme.json"
const MAX_THEMES = 32

Expand Down Expand Up @@ -59,6 +80,57 @@ function sourceDecodes(source) {
return decodesToString(source.url) && decodesToString(source.version)
}

// tagError is tags.Parse's verdict on a syntax key: a tag name, or a modifier
// wrapping one, as in definition(propertyName).
function tagError(expr) {
const open = expr.indexOf("(")
if (open < 0) {
if (expr.includes(")")) return `"${expr}" closes a parenthesis it never opened`
return TAG_NAMES.has(expr) ? null : `"${expr}" is not a highlighting tag`
}
const modifier = expr.slice(0, open)
if (!TAG_MODIFIERS.includes(modifier))
return `"${modifier}" in "${expr}" is not a tag modifier: want one of ${TAG_MODIFIERS.join(", ")}`
if (!expr.endsWith(")")) return `"${expr}" must end with the parenthesis its modifier opens`
return tagError(expr.slice(open + 1, -1))
}

function syntaxErrors(syntax) {
if (syntax === undefined || syntax === null) return []
if (typeof syntax !== "object" || Array.isArray(syntax))
return ["syntax must be an object of tag styles"]
const errors = []
for (const [tag, entry] of Object.entries(syntax)) {
const style = typeof entry === "string" ? { color: entry } : entry
if (!style || typeof style !== "object" || Array.isArray(style)) {
errors.push(`syntax.${tag} must be a color or a style object`)
continue
}
const problem = tagError(tag)
if (problem) errors.push(`syntax: ${problem}`)
const color = String(style.color ?? "").trim()
if (!color) {
if (!FONT_STYLES.some((font) => style[font] === true))
errors.push(`syntax.${tag} sets no color and no font style`)
} else if (!APP_COLOR.test(color) || color.length > 128) {
errors.push(`syntax.${tag} is not an accepted color value: ${JSON.stringify(style.color)}`)
}
}
return errors
}

// pairErrors is what one file can tell about its pair. Whether a sibling it
// names has the other scheme is checked with the repository's themes.
function pairErrors(theme) {
const pair = theme.pair
if (pair === undefined || pair === null || pair === "") return []
if (pair === theme.id) return [`theme "${theme.id}" cannot be its own pair`]
if (typeof pair !== "string" || !ID.test(pair)) return [`pair: id ${JSON.stringify(pair)} must match ${ID}`]
if (BUNDLED_SCHEMES[pair] === theme.scheme)
return [`pair "${pair}" is a ${theme.scheme} theme too; a pair has the other scheme`]
return []
}

function themeErrors(theme) {
const errors = []
const format = formatVersionError(theme)
Expand Down Expand Up @@ -87,6 +159,7 @@ function themeErrors(theme) {
}
checkColors("app", APP, APP_COLOR, APP)
checkColors("terminal", TERMINAL, HEX, ["background", "foreground"])
errors.push(...syntaxErrors(theme.syntax), ...pairErrors(theme))
return errors
}

Expand Down Expand Up @@ -135,6 +208,7 @@ function checkRepository(dir) {
if (files.length > MAX_THEMES) errors.push(`more than ${MAX_THEMES} themes in one repository`)

const seen = new Map()
const schemes = new Map()
for (const name of files) {
const path = join(dir, name)
errors.push(...checkTheme(path))
Expand All @@ -143,6 +217,13 @@ function checkRepository(dir) {
if (!id) continue
if (seen.has(id)) errors.push(`${name}: id "${id}" is already used by ${seen.get(id)}`)
else seen.set(id, name)
schemes.set(id, value.scheme)
}
// lich lists a theme whose pair has its own scheme as one that can't load.
for (const name of files) {
const { value } = read(join(dir, name))
if (value?.pair && value.pair !== value.id && schemes.get(value.pair) === value.scheme)
errors.push(`${name}: pair "${value.pair}" is a ${value.scheme} theme too; a pair has the other scheme`)
}
return errors
}
Expand Down
2 changes: 1 addition & 1 deletion tests/lich-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.65.0
c42e0ea2e56c3a0986948ed91033ee3be9b0d5ae
Loading
Loading