From ae5c450007b68766ad882a9eec26cda6efcd3e5e Mon Sep 17 00:00:00 2001 From: omartelo Date: Thu, 15 Oct 2026 13:25:12 -0300 Subject: [PATCH] feat(theme): write and validate a theme's syntax and pair The skill ports a palette's code colors into syntax and pairs a palette's light and dark variants. validate.mjs checks syntax tags, modifiers and styles, and pairs (own id, same scheme in the pack or bundled), as lich's validateSyntax and checkPairs do. tests/lich-ref pins the lich commit whose template shows both, until a lich release ships them. --- CHANGELOG.md | 10 ++++ skills/theme/SKILL.md | 91 ++++++++++++++++++++++++++++++++--- skills/theme/template.json | 46 ++++++++++++++++++ skills/theme/validate.mjs | 81 +++++++++++++++++++++++++++++++ tests/lich-ref | 2 +- tests/theme-validate.test.mjs | 70 +++++++++++++++++++++++++++ 6 files changed, 292 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51d0fb..62e214a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/skills/theme/SKILL.md b/skills/theme/SKILL.md index bae48fd..dab44f3 100644 --- a/skills/theme/SKILL.md +++ b/skills/theme/SKILL.md @@ -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. @@ -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 ` (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 ` (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 `.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. @@ -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 @@ -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 `.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. @@ -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 @@ -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 @@ -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 diff --git a/skills/theme/template.json b/skills/theme/template.json index df3e3d3..1fba94e 100644 --- a/skills/theme/template.json +++ b/skills/theme/template.json @@ -3,6 +3,7 @@ "id": "purple-night", "name": "Purple Night", "scheme": "dark", + "pair": "purple-day", "app": { "background": "#15111f", "foreground": "#f4efff", @@ -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 + } } } diff --git a/skills/theme/validate.mjs b/skills/theme/validate.mjs index 9f5a652..05f0346 100644 --- a/skills/theme/validate.mjs +++ b/skills/theme/validate.mjs @@ -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 @@ -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) @@ -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 } @@ -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)) @@ -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 } diff --git a/tests/lich-ref b/tests/lich-ref index 591b887..09889e7 100644 --- a/tests/lich-ref +++ b/tests/lich-ref @@ -1 +1 @@ -v0.65.0 +c42e0ea2e56c3a0986948ed91033ee3be9b0d5ae diff --git a/tests/theme-validate.test.mjs b/tests/theme-validate.test.mjs index e1eb00d..128c329 100644 --- a/tests/theme-validate.test.mjs +++ b/tests/theme-validate.test.mjs @@ -88,3 +88,73 @@ test('a directory named like a theme is no theme, so a pack of only that has non assert.equal(result.status, 1) assert.match(result.stderr, /no theme JSON next to lich-theme\.json/) }) + +// syntax and pair, as lich's internal/themes validateSyntax and checkPairs +// read them. + +test('the template\'s syntax section passes, bare colors and style objects alike', () => { + const result = validateTheme({}) + assert.equal(result.status, 0, result.stderr) +}) + +test('a syntax key that is no highlighting tag is rejected', () => { + const result = validateTheme({ syntax: { keywrd: '#ff0000' } }) + assert.equal(result.status, 1) + assert.match(result.stderr, /syntax: "keywrd" is not a highlighting tag/) +}) + +test('a modifier wraps a tag, and an unknown modifier is rejected', () => { + assert.equal(validateTheme({ syntax: { 'definition(propertyName)': '#7fbbb3' } }).status, 0) + const result = validateTheme({ syntax: { 'bold(keyword)': '#ff0000' } }) + assert.equal(result.status, 1) + assert.match(result.stderr, /"bold" in "bold\(keyword\)" is not a tag modifier/) +}) + +test('a syntax style needs a color or a font style, and an accepted color', () => { + let result = validateTheme({ syntax: { keyword: {} } }) + assert.equal(result.status, 1) + assert.match(result.stderr, /syntax\.keyword sets no color and no font style/) + result = validateTheme({ syntax: { keyword: 'var(--x)' } }) + assert.equal(result.status, 1) + assert.match(result.stderr, /syntax\.keyword is not an accepted color value/) + assert.equal(validateTheme({ syntax: { emphasis: { italic: true } } }).status, 0) +}) + +test('a theme cannot be its own pair, nor pair with a bundled theme of its scheme', () => { + let result = validateTheme({ id: 'night', pair: 'night' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /cannot be its own pair/) + result = validateTheme({ id: 'night', pair: 'dark' }) + assert.equal(result.status, 1) + assert.match(result.stderr, /pair "dark" is a dark theme too/) + assert.equal(validateTheme({ id: 'night', pair: 'light' }).status, 0) +}) + +function pack(themes) { + const dir = mkdtempSync(join(tmpdir(), 'lich-pack-')) + writeFileSync(join(dir, 'lich-theme.json'), JSON.stringify({ name: 'Pack', version: '1.0.0' })) + for (const theme of themes) writeFileSync(join(dir, `${theme.id}.json`), JSON.stringify({ ...template, ...theme })) + return dir +} + +test('a pack\'s pairs pass when they cross schemes', () => { + const result = validatePath(pack([ + { id: 'forest-night', scheme: 'dark', pair: 'forest-day' }, + { id: 'forest-day', scheme: 'light', pair: 'forest-night' }, + ])) + assert.equal(result.status, 0, result.stderr) +}) + +test('a pair of the same scheme fails the pack, as lich lists that theme as broken', () => { + const result = validatePath(pack([ + { id: 'forest-night', scheme: 'dark', pair: 'forest-dusk' }, + { id: 'forest-dusk', scheme: 'dark' }, + ])) + assert.equal(result.status, 1) + assert.match(result.stderr, /forest-night\.json: pair "forest-dusk" is a dark theme too/) +}) + +test('a pair no theme answers to passes: the pack may be partly removed', () => { + const result = validatePath(pack([{ id: 'forest-night', scheme: 'dark', pair: 'forest-day' }])) + assert.equal(result.status, 0, result.stderr) +})