feat(plugins): Theme plugin template, theme docs and IntelliJ theme import (#54)

Per the maintainer's decision for #24, no third-party themes are bundled; instead a theme plugin template and docs let the community port themes (IntelliJ also ships few themes, the rest are plugins).
- templates/theme-plugin: manifest-only plugin with an original example (Harbor Night) covering every UI token and an editor scheme (.icls equivalent).
- docs/plugins/themes.md: format, IntelliJ / VS Code porting tables, generated token reference (pnpm gen:theme-docs); drift test fails when tokens.css and the template/reference disagree.
- pnpm theme:import-intellij: dependency-free converter for IntelliJ .theme.json (+ .icls).
- Plugin host: themes may name an editor-scheme merged over the theme; comments allowed in theme files.
- Fixes: a plugin theme's kind now switches the light/dark base; plugin themes go in their own stylesheet so the Appearance accent and editor scheme still layer on top.
Verified: ide-plugin tests, vitest (44), in-app on macOS: installed the template from disk, applied Harbor Night (dark base + teal accent over Islands Light), back to default restored the light theme.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 10:39:08 +08:00
committed by GitHub
parent b2fec4ae30
commit 30310810e6
22 changed files with 1885 additions and 34 deletions
+7 -2
View File
@@ -20,7 +20,9 @@ mod sandbox;
mod theme;
mod types;
pub use manifest::{API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest};
pub use manifest::{
API_VERSION, CommandDecl, Contributions, FileDecl, MANIFEST_FILE, Permission, PluginManifest, ThemeDecl,
};
pub use sandbox::resolve_in_root;
pub use theme::{ThemeDef, ThemeKind};
pub use types::*;
@@ -627,7 +629,10 @@ fn static_contributions(dir: &Path, m: &PluginManifest) -> Result<Registrations,
regs.add_language(cfg);
}
for f in &m.contributes.themes {
let theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
let mut theme = ThemeDef::parse(&read(&f.path)?).map_err(|e| format!("{}: {e}", f.path.display()))?;
if let Some(scheme) = &f.editor_scheme {
theme.merge_editor_scheme(&read(scheme)?).map_err(|e| format!("{}: {e}", scheme.display()))?;
}
regs.add_theme(theme);
}
Ok(regs)
+25 -5
View File
@@ -64,7 +64,7 @@ pub struct Contributions {
#[serde(default)]
pub languages: Vec<FileDecl>,
#[serde(default)]
pub themes: Vec<FileDecl>,
pub themes: Vec<ThemeDecl>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
@@ -83,6 +83,16 @@ pub struct FileDecl {
pub path: PathBuf,
}
/// A contributed theme: the UI theme file plus an optional editor colour scheme
/// whose tokens are merged over it (IntelliJ's `.theme.json` + `.icls`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ThemeDecl {
pub path: PathBuf,
#[serde(default)]
pub editor_scheme: Option<PathBuf>,
}
impl PluginManifest {
pub fn parse(src: &str) -> Result<Self, String> {
let manifest: Self = toml::from_str(src).map_err(|e| e.to_string())?;
@@ -123,8 +133,9 @@ impl PluginManifest {
return Err(format!("command {:?} has an empty title", c.id));
}
}
for f in self.contributes.languages.iter().chain(&self.contributes.themes) {
check_relative(&f.path, "contributes path")?;
let theme_files = self.contributes.themes.iter().flat_map(|t| std::iter::once(&t.path).chain(&t.editor_scheme));
for path in self.contributes.languages.iter().map(|f| &f.path).chain(theme_files) {
check_relative(path, "contributes path")?;
}
Ok(())
}
@@ -133,7 +144,10 @@ impl PluginManifest {
pub fn files(&self) -> Vec<&Path> {
let mut files: Vec<&Path> = self.wasm.iter().map(PathBuf::as_path).collect();
files.extend(self.contributes.languages.iter().map(|f| f.path.as_path()));
files.extend(self.contributes.themes.iter().map(|f| f.path.as_path()));
for t in &self.contributes.themes {
files.push(t.path.as_path());
files.extend(t.editor_scheme.as_deref());
}
files
}
@@ -202,6 +216,7 @@ mod tests {
[[contributes.themes]]
path = "themes/dark.json"
editor-scheme = "themes/dark.scheme.json"
"#;
#[test]
@@ -210,7 +225,10 @@ mod tests {
assert_eq!(m.id, "hello");
assert_eq!(m.permissions, vec![Permission::ProjectRead, Permission::EditorWrite]);
assert_eq!(m.contributes.commands[0].category.as_deref(), Some("Hello"));
assert_eq!(m.files(), vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json")]);
assert_eq!(
m.files(),
vec![Path::new("dist/plugin.wasm"), Path::new("themes/dark.json"), Path::new("themes/dark.scheme.json")]
);
assert!(m.has(Permission::EditorWrite) && !m.has(Permission::EditorRead));
}
@@ -242,6 +260,8 @@ mod tests {
assert!(err(&base(dup, "ok", "0.1")).contains("duplicate command"));
let traversal = "[[contributes.languages]]\npath = \"a/../../x.toml\"";
assert!(err(&base(traversal, "ok", "0.1")).contains("relative path"));
let scheme_escape = "[[contributes.themes]]\npath = \"t.json\"\neditor-scheme = \"../s.json\"";
assert!(err(&base(scheme_escape, "ok", "0.1")).contains("relative path"));
assert!(err("id = \"ok\"\nname = \"N\"\nversion = \"1.0\"\nopenidea-api = \"0.1\"").contains("version"));
assert!(err("id = \"ok\"\nname = \" \"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"").contains("name"));
}
+94 -1
View File
@@ -1,4 +1,5 @@
//! Theme contributions: design-token overrides applied as CSS variables on `:root`.
//! Theme and editor-scheme files are JSON that may contain `//` and `/* */` comments.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -24,13 +25,28 @@ pub struct ThemeDef {
pub tokens: BTreeMap<String, String>,
}
/// `{"name"?, "tokens": {...}}`: an editor colour scheme merged over a theme.
#[derive(Deserialize)]
struct EditorScheme {
tokens: BTreeMap<String, String>,
}
impl ThemeDef {
pub fn parse(src: &str) -> Result<Self, String> {
let theme: Self = serde_json::from_str(src).map_err(|e| format!("invalid theme JSON: {e}"))?;
let theme: Self =
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid theme JSON: {e}"))?;
theme.validate()?;
Ok(theme)
}
/// Merges an editor colour scheme file over the theme's tokens (the scheme wins).
pub fn merge_editor_scheme(&mut self, src: &str) -> Result<(), String> {
let scheme: EditorScheme =
serde_json::from_str(&strip_json_comments(src)).map_err(|e| format!("invalid editor scheme JSON: {e}"))?;
self.tokens.extend(scheme.tokens);
self.validate()
}
pub fn validate(&self) -> Result<(), String> {
crate::manifest::validate_command_id(&self.id).map_err(|e| e.replace("command id", "theme id"))?;
if self.name.trim().is_empty() {
@@ -55,6 +71,53 @@ impl ThemeDef {
}
}
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
/// parse errors still point at the right line.
fn strip_json_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
let (mut in_str, mut escaped) = (false, false);
while let Some(c) = chars.next() {
if in_str {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match (c, chars.peek()) {
('/', Some('/')) => {
if chars.by_ref().any(|c| c == '\n') {
out.push('\n');
}
}
('/', Some('*')) => {
chars.next();
let mut prev = ' ';
for c in chars.by_ref() {
if c == '\n' {
out.push('\n');
}
if prev == '*' && c == '/' {
break;
}
prev = c;
}
out.push(' ');
}
_ => {
in_str = c == '"';
out.push(c);
}
}
}
out
}
/// Values are plain CSS values (colours, lengths, font stacks). Anything that
/// could break out of the declaration or fetch resources is rejected.
fn is_safe_value(v: &str) -> bool {
@@ -95,4 +158,34 @@ mod tests {
assert!(mk("--bg", " ").is_err());
assert!(ThemeDef::parse("{\"id\":\"x\"}").is_err());
}
#[test]
fn allows_comments_outside_strings() {
let src = r##"{
// line comment
"id": "c", "name": "C // not a comment", "kind": "dark",
/* block
comment */
"tokens": { "--bg-app": "#000" /* inline */, "--font-ui": "\"a/*b\", sans-serif" }
}"##;
let t = ThemeDef::parse(src).unwrap();
assert_eq!(t.name, "C // not a comment");
assert_eq!(t.tokens["--font-ui"], "\"a/*b\", sans-serif");
assert!(ThemeDef::parse("{\n // x\n \"id\": 1\n}").unwrap_err().contains("line 3"));
}
#[test]
fn merges_editor_scheme_over_theme() {
let mut t = ThemeDef::parse(
r##"{"id":"m","name":"M","kind":"dark","tokens":{"--bg-app":"#111","--syn-keyword":"#222"}}"##,
)
.unwrap();
t.merge_editor_scheme(r##"{"name":"M", /* editor */ "tokens":{"--syn-keyword":"#333","--caret":"#fff"}}"##)
.unwrap();
assert_eq!(t.tokens["--bg-app"], "#111");
assert_eq!(t.tokens["--syn-keyword"], "#333");
assert_eq!(t.tokens["--caret"], "#fff");
assert!(t.clone().merge_editor_scheme(r#"{"tokens":{"--caret":"url(x)"}}"#).is_err());
assert!(t.merge_editor_scheme(r#"{"name":"no tokens"}"#).is_err());
}
}
+36
View File
@@ -242,6 +242,42 @@ fn contribution_only_plugins() {
assert_eq!(f.status("language-toy"), PluginStatus::Disabled);
}
#[test]
fn theme_template_installs_with_its_editor_scheme() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&repo().join("templates/theme-plugin")).unwrap();
assert_eq!(info.status, PluginStatus::Static);
assert!(!info.has_code);
assert_eq!(info.themes[0].id, "harbor-night");
assert_eq!(info.themes[0].kind, ThemeKind::Dark);
// The scheme file is copied with the package and merged over the UI tokens.
assert!(f.plugins_dir.join("theme-harbor-night/themes/harbor-night.scheme.json").is_file());
let active = f.host.set_theme(Some(("theme-harbor-night", "harbor-night"))).unwrap().unwrap();
let tokens = &active.theme.tokens;
assert!(
tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red")
);
assert!(tokens.len() > 150);
}
#[test]
fn theme_with_broken_editor_scheme_fails_to_load() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("t")).unwrap();
std::fs::write(
dir.path().join("plugin.toml"),
"id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nopenidea-api = \"0.1\"\n\
[[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n",
)
.unwrap();
std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap();
std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap();
let info = f.host.install_from_dir(dir.path()).unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.unwrap_or_default().contains("a.scheme.json"));
}
#[test]
fn state_persists_across_restarts() {
let f = fixture(Limits::default());
+551
View File
@@ -0,0 +1,551 @@
# Theme plugins
OpenIDEA ships three built-in themes (Islands Light, Islands Dark, High
Contrast). Everything else comes from **theme plugins**, the same model as
IntelliJ IDEA, where most themes are plugins rather than part of the IDE.
A theme plugin is data only. Like an IntelliJ theme (a `.theme.json` plus an
optional `.icls` editor scheme) it has no code: no WebAssembly, nothing to
compile, no permissions.
```
my-theme/
├─ plugin.toml # manifest
└─ themes/
├─ my-theme.theme.json # UI colours (IntelliJ: .theme.json)
└─ my-theme.scheme.json # editor colours, optional (IntelliJ: .icls)
```
The quickest way to start is to copy [`templates/theme-plugin`](../../templates/theme-plugin).
It contains *Harbor Night*, a complete example dark theme that sets every
themeable token. The rest of this page covers the file format, the token
reference, porting themes from IntelliJ IDEA and VS Code, and testing.
## The manifest
```toml
id = "theme-harbor-night" # [a-z0-9][a-z0-9_-]*, unique among plugins
name = "Harbor Night Theme"
version = "1.0.0"
description = "..."
authors = ["You <you@example.com>"]
openidea-api = "0.1"
[[contributes.themes]] # one entry per theme; a plugin may ship several
path = "themes/harbor-night.theme.json"
editor-scheme = "themes/harbor-night.scheme.json" # optional
```
Paths are relative to the package directory and may not leave it. The other
manifest fields are described in [`plugins/README.md`](../../plugins/README.md).
## The theme file
```jsonc
{
"id": "harbor-night", // [a-z0-9][a-z0-9._-]*, unique within the plugin
"name": "Harbor Night", // shown in Settings › Plugins
"kind": "dark", // "dark" or "light": the built-in base
"tokens": {
"--bg-app": "#14171c",
"--accent": "#2b9e93"
}
}
```
- **`tokens`** are the CSS variables of [`src/styles/tokens.css`](../../src/styles/tokens.css),
the IDE's single source of colours. Components never use colour literals, so
every surface, border, text colour, syntax colour and status colour can be
themed through them. The [reference](#token-reference) lists them all.
- **`kind`** picks the built-in base. While the theme is active the IDE runs on
Islands Dark or Islands Light, and every token the theme leaves out keeps
that base's value. Icons also use the light or dark set that matches the
kind. A partial theme still looks consistent, but listing every token keeps
it stable when the IDE's own palette changes.
- Values are plain CSS values: hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`,
`hsl()`, `color-mix()`, `var(--other-token)`, or for the few non-colour
tokens a keyword (`italic`, `normal`, `none`) or shadow list. `url()`,
`image()`, `;`, braces, `<`, `>` and `\` are rejected.
- `//` and `/* */` comments are allowed, as in VS Code theme files.
- Tokens that tokens.css derives from others (for example `--bg-editor` is
`var(--bg-island)`, and the coverage and flame graph colours are mixed from
the status colours) follow automatically. You may still set them.
- Metrics (`--fs-*`, `--h-*`, `--radius-*`) and fonts (`--font-ui`,
`--font-mono`) can be overridden too, but a theme normally leaves them alone
so that compact mode and the user's font settings keep working.
### The editor colour scheme file
```jsonc
{
"name": "Harbor Night",
"tokens": { "--syn-keyword": "#e0a46e", "--caret": "#5ce0d2" }
}
```
Its tokens are merged over the theme's, and the scheme wins where both set a
token. By convention it holds the *syntax*, *editor* and *ANSI palette* groups
of the reference, the parts IntelliJ keeps in an `.icls` file. The split only
organises the files: a theme can also put everything in its theme file.
### How a theme is applied
The selected theme is injected as one stylesheet on `:root` that outranks the
built-in theme blocks, and `data-theme` is switched to the theme's `kind`.
Three things still layer on top of it:
- an accent colour picked in *Settings › Appearance* (other than the default),
- an explicit scheme or attribute overrides in *Settings › Editor › Color Scheme*,
- the appearance options that change only metrics (compact mode).
The selection is remembered. When the plugin is disabled or uninstalled the
built-in theme returns.
## Building, packaging, installing
There is no build step. The package *is* the folder.
1. Copy `templates/theme-plugin` and rename `id`, `name` and the theme files.
2. Edit the colours. Keep `kind` truthful (`light` for light backgrounds).
3. **Install:** *Settings › Plugins › ⚙ › Install Plugin from Disk…* (or the
*Install Plugin from Disk…* action) and pick the package folder. The
manifest and the files it references are copied to
`<app-data>/plugins/<id>/`.
4. **Apply:** in *Settings › Plugins*, select the plugin and press *Apply* next
to the theme. *Use Default Theme* switches back.
To iterate without reinstalling, put (or symlink) the package in
`<project>/.openidea/plugins/<name>/` of an open project. It loads as a *dev*
plugin, and *Reload* (or the *Reload Plugins* action) picks up your edits.
To share a theme, publish the folder (a Git repository or a zip). Users
install it from disk.
## Porting a theme
Only port themes whose licence allows redistribution, keep their licence file,
and credit the original authors in `authors` and `description`.
### From IntelliJ IDEA (automatic)
`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`,
and optionally its `.icls` editor scheme, into a ready-to-install plugin. It
has no dependencies beyond Node 22.
```sh
pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author"
# or: node scripts/theme/import-intellij-theme.ts …
```
It maps the keys in the tables below, resolves named colours from the theme's
`colors` section and `*` wildcards, and takes `kind` from `"dark"` (or the
`.icls` `parent_scheme`). Tokens without an IntelliJ counterpart keep the
built-in values for that kind. Open the result next to the template and fill
in what matters to you. Themes that ship only a `.theme.json` with an
`editorScheme` path need that `.icls` passed as the second argument. Themes that
ship no `.icls` at all keep the built-in syntax colours.
#### `.theme.json` → UI theme
| OpenIDEA token | IntelliJ `ui` key (first present wins) |
|---|---|
| `--bg-app` | `Panel.background` |
| `--bg-frame` | `MainWindow.background`, `MainToolbar.background` |
| `--bg-island`, `--bg-elev` | `ToolWindow.background` |
| `--bg-popup` | `Popup.background`, `PopupMenu.background` |
| `--bg-hover` | `List.hoverBackground`, `Tree.hoverBackground` |
| `--bg-selected`, `--bg-tree-selected` | `List.selectionBackground`, `Tree.selectionBackground` |
| `--bg-tree-selected-inactive` | `Tree.selectionInactiveBackground` |
| `--bg-input`, `--bg-field` | `TextField.background` |
| `--border` | `Borders.color` |
| `--border-strong`, `--border-window` | `Component.borderColor`, `Borders.ContrastBorderColor` |
| `--fg`, `--fg-emphasis` | `Label.foreground` |
| `--fg-secondary`, `--fg-muted` | `Label.infoForeground`, `Component.infoForeground` |
| `--fg-subtle`, `--fg-faint` | `Label.disabledForeground` |
| `--accent` | `Button.default.startBackground`, `Component.focusColor` |
| `--fg-on-accent`, `--fg-button-primary` | `Button.default.foreground` |
| `--link`, `--link-hover` | `Link.activeForeground`, `Link.hoverForeground` |
| `--danger`, `--warning` | `Component.errorFocusColor`, `Component.warningFocusColor` |
| `--bg-tooltip`, `--fg-tooltip`, `--border-tooltip` | `ToolTip.background`, `.foreground`, `.borderColor` |
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected`, `--border-tab-selected` | `EditorTabs.underlinedTabBackground`, `EditorTabs.underlineColor` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `ScrollBar.Mac.thumbColor`, `ScrollBar.Mac.hoverThumbColor` |
| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` |
| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … |
The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts).
#### `.icls` → editor scheme
| OpenIDEA token | `.icls` option |
|---|---|
| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) |
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
| `--vcs-*`, `--gutter-*` | colour `FILESTATUS_*`, `MODIFIED_LINES_COLOR`, `ADDED_LINES_COLOR`, `DELETED_LINES_COLOR` |
| `--syn-keyword`, `--syn-string`, `--syn-number` | `DEFAULT_KEYWORD`, `DEFAULT_STRING`, `DEFAULT_NUMBER` |
| `--syn-comment` (+ `--syn-comment-style`), `--syn-doc-comment` | `DEFAULT_LINE_COMMENT` (+ `FONT_TYPE`), `DEFAULT_DOC_COMMENT` |
| `--syn-function`, `--syn-function-call` | `DEFAULT_FUNCTION_DECLARATION`, `DEFAULT_FUNCTION_CALL` |
| `--syn-type`, `--syn-constant`, `--syn-property` | `DEFAULT_CLASS_NAME`, `DEFAULT_CONSTANT`, `DEFAULT_INSTANCE_FIELD` |
| `--syn-variable`, `--syn-param`, `--syn-builtin` | `DEFAULT_LOCAL_VARIABLE`, `DEFAULT_PARAMETER`, `DEFAULT_PREDEFINED_SYMBOL` |
| `--syn-annotation`, `--syn-decorator` | `DEFAULT_METADATA` |
| `--syn-punct`, `--syn-escape` | `DEFAULT_OPERATION_SIGN`, `DEFAULT_VALID_STRING_ESCAPE` |
| `--syn-tag`, `--syn-attribute`, `--syn-todo`, `--syn-invalid` | `DEFAULT_TAG`, `DEFAULT_ATTRIBUTE`, `TODO_DEFAULT_ATTRIBUTES`, `BAD_CHARACTER` |
| `--wave-error`, `--wave-warning` | `ERRORS_ATTRIBUTES`, `WARNING_ATTRIBUTES` (effect colour) |
| `--bg-matched-brace`, `--bg-identifier-under-caret` | `MATCHED_BRACE_ATTRIBUTES`, `IDENTIFIER_UNDER_CARET_ATTRIBUTES` |
| `--bg-text-search-result`, `--bg-search-match` | `TEXT_SEARCH_RESULT_ATTRIBUTES`, `SEARCH_RESULT_ATTRIBUTES` |
| `--syn-self`, `--syn-keyword-arg`, `--syn-docstring`, `--syn-predefined` | `PY.SELF_PARAMETER`, `PY.KEYWORD_ARGUMENT`, `PY.DOC_COMMENT`, `PY.BUILTIN_NAME` |
| `--ansi-*` | `CONSOLE_*_OUTPUT` (`CONSOLE_DARKGRAY_OUTPUT` is bright black, `CONSOLE_GRAY_OUTPUT` white) |
The full tables are `ICLS_COLORS` and `ICLS_ATTRIBUTES` in the same file.
### From VS Code (by hand)
A VS Code theme (`themes/*.json` in the extension) has `type` (`dark` / `light`
/ `hc`), `colors` (workbench) and `tokenColors` (TextMate scopes). Set `kind`
from `type`, then map:
| OpenIDEA token | VS Code `colors` key |
|---|---|
| `--bg-frame` | `titleBar.activeBackground`, `activityBar.background` |
| `--bg-island`, `--bg-app` | `editor.background`, `sideBar.background` |
| `--bg-elev` | `sideBarSectionHeader.background`, `editorGroupHeader.tabsBackground` |
| `--bg-popup`, `--border-popup` | `editorWidget.background`, `editorWidget.border` |
| `--bg-hover` | `list.hoverBackground` |
| `--bg-selected`, `--bg-tree-selected` | `list.activeSelectionBackground` |
| `--bg-tree-selected-inactive` | `list.inactiveSelectionBackground` |
| `--border` | `panel.border`, `sideBar.border` |
| `--fg` | `foreground`, `sideBar.foreground` |
| `--fg-muted`, `--fg-subtle` | `descriptionForeground`, `disabledForeground` |
| `--accent`, `--fg-on-accent` | `button.background`, `button.foreground` |
| `--focus-ring-soft` | `focusBorder` |
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
| `--fg-editor` | `editor.foreground` |
| `--caret` | `editorCursor.foreground` |
| `--bg-current-line` | `editor.lineHighlightBackground` |
| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` |
| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` |
| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` |
| `--fg-whitespace` | `editorWhitespace.foreground` |
| `--bg-text-search-result`, `--bg-text-search-result-current` | `editor.findMatchHighlightBackground`, `editor.findMatchBackground` |
| `--bg-matched-brace` | `editorBracketMatch.background` |
| `--bg-identifier-under-caret`, `-write` | `editor.wordHighlightBackground`, `editor.wordHighlightStrongBackground` |
| `--wave-error`, `--wave-warning` | `editorError.foreground`, `editorWarning.foreground` |
| `--gutter-modified`, `--gutter-added`, `--gutter-deleted` | `editorGutter.modifiedBackground`, `.addedBackground`, `.deletedBackground` |
| `--fg-inlay`, `--bg-inlay` | `editorInlayHint.foreground`, `editorInlayHint.background` |
| `--ansi-*` | `terminal.ansiBlack` … `terminal.ansiBrightWhite` |
`tokenColors` → syntax tokens, by TextMate scope:
| OpenIDEA token | Scope |
|---|---|
| `--syn-keyword` | `keyword`, `storage.type`, `storage.modifier` |
| `--syn-string` | `string` |
| `--syn-escape` | `constant.character.escape` |
| `--syn-number` | `constant.numeric` |
| `--syn-constant` | `constant.language`, `variable.other.constant` |
| `--syn-comment`, `--syn-doc-comment` | `comment`, `comment.block.documentation` |
| `--syn-function`, `--syn-function-call` | `entity.name.function`, `meta.function-call` |
| `--syn-type` | `entity.name.type`, `entity.name.class`, `support.class` |
| `--syn-variable`, `--syn-param` | `variable`, `variable.parameter` |
| `--syn-property` | `variable.other.property`, `variable.other.member` |
| `--syn-builtin`, `--syn-predefined` | `support.function`, `support.variable` |
| `--syn-self` | `variable.language.self`, `variable.language.this` |
| `--syn-annotation`, `--syn-decorator` | `meta.decorator`, `storage.type.annotation` |
| `--syn-punct` | `punctuation`, `keyword.operator` |
| `--syn-tag`, `--syn-attribute` | `entity.name.tag`, `entity.other.attribute-name` |
| `--syn-regexp` | `string.regexp` |
| `--syn-markup-heading`, `--syn-markup-code`, `--syn-link` | `markup.heading`, `markup.inline.raw`, `markup.underline.link` |
| `--syn-invalid` | `invalid` |
A `fontStyle: "italic"` on comments maps to `"--syn-comment-style": "italic"`.
## Testing a theme
- Install it from disk (or as a dev plugin), apply it, and walk through: the
Project tool window with a selection, the editor with a few languages, Search
Everywhere, a popup menu, a tooltip, Settings, the terminal (`ls --color`),
a diff, and the VCS colours in the Commit tool window.
- *Settings › Plugins* shows a load error (with the file and line) if the JSON
or a value is invalid. *Reload* re-reads a dev plugin.
- Text should keep at least WCAG AA contrast (4.5:1) on its surfaces. The
built-in themes are checked by `src/features/settings/__tests__/contrast.test.ts`.
- Repository checks (for changes to this repo): `pnpm vitest run scripts/theme`
validates the template against tokens.css and keeps this reference current,
and `cargo test -p ide-plugin` loads the template through the plugin host.
## Token reference
Generated from `src/styles/tokens.css` (the variables of its Islands Light
block, which redefines every colour token of Islands Dark). `pnpm gen:theme-docs`
regenerates it. The `scripts/theme` tests fail when it, or the template, misses
a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
194 themeable tokens: 112 in the UI theme file, 82 in the editor colour scheme file.
### Surfaces (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--bg-app` | `#191a1c` | `#ffffff` |
| `--bg-elev` | `#212326` | `#f7f8f9` |
| `--bg-popup` | `#26282c` | `#ffffff` |
| `--bg-hover` | `#2e2f30` | `#ededed` |
| `--bg-selected` | `#2a4371` | `#d0dffe` |
| `--bg-selected-strong` | `#2e4d89` | `#bdd3ff` |
| `--bg-current-line` | `#1f2024` | `#f5f8fe` |
| `--bg-debug-line` | `#2a5091` | `#d4e2ff` |
| `--bg-backdrop` | `#26282c` | `#e9eaee` |
| `--bg-input` | `#191a1c` | `#ffffff` |
### Lines (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--border` | `#26282c` | `#e9eaee` |
| `--border-popup` | `#33353b` | `#d1d3d9` |
| `--border-strong` | `#40434a` | `#d1d3d9` |
| `--border-window` | `#40434a` | `#d1d3d9` |
| `--control-off` | `#5f6269` | `#c3c5cb` |
### Text (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--fg-strong` | `#dfe1e5` | `#000000` |
| `--fg-emphasis` | `#d1d3d9` | `#000000` |
| `--fg` | `#d1d3d9` | `#000000` |
| `--fg-secondary` | `#9fa2a8` | `#5f6269` |
| `--fg-muted` | `#8c8f95` | `#6c707e` |
| `--fg-subtle` | `#73767c` | `#73767c` |
| `--fg-faint` | `#5f6269` | `#9fa2a8` |
| `--fg-gutter` | `#4b5059` | `#aeb3c2` |
| `--fg-icon` | `#ced0d6` | `#6c707e` |
### Accent (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--accent` | `#3871e1` | `#3871e1` |
| `--link` | `#71a1fe` | `#2f5eb9` |
| `--link-hover` | `#92b7ff` | `#2e4d89` |
### Status (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--danger` | `#f57e84` | `#c54e58` |
| `--warning` | `#d59637` | `#a56906` |
| `--success` | `#6db083` | `#338555` |
| `--db-connected` | `#2fe02f` | `#00ff00` |
| `--db-connected-ring` | `#1d6b1d` | `#5b995b` |
| `--info` | `#548af7` | `#3871e1` |
### Vcs (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--vcs-modified` | `#70aeff` | `#0033b3` |
| `--vcs-added` | `#73bd79` | `#067d17` |
| `--vcs-deleted` | `#6f737a` | `#6c707e` |
| `--vcs-untracked` | `#e88f89` | `#b23247` |
| `--vcs-ignored` | `#d69a6b` | `#8c4f00` |
| `--vcs-conflict` | `#de6a66` | `#de1b2e` |
| `--gutter-modified` | `#375fad` | `#88adf7` |
| `--gutter-added` | `#549159` | `#7fc784` |
| `--gutter-deleted` | `#868a91` | `#767a8a` |
### Syntax (editor scheme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--syn-keyword` | `#cf8e6d` | `#0033b3` |
| `--syn-string` | `#6aab73` | `#067d17` |
| `--syn-type` | `#bcbec4` | `#000000` |
| `--syn-function` | `#56a8f5` | `#00627a` |
| `--syn-function-call` | `#bcbec4` | `#080808` |
| `--syn-comment` | `#7a7e85` | `#8c8c8c` |
| `--syn-comment-style` | `normal` | `italic` |
| `--syn-doc-comment` | `#5f826b` | `#8c8c8c` |
| `--syn-docstring` | `#5f826b` | `#067d17` |
| `--syn-annotation` | `#b3ae60` | `#9e880d` |
| `--syn-decorator` | `#b3ae60` | `#9e880d` |
| `--syn-number` | `#2aacb8` | `#1750eb` |
| `--syn-punct` | `#bcbec4` | `#080808` |
| `--syn-variable` | `#bcbec4` | `#080808` |
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
| `--syn-predefined` | `#b200b2` | `#b200b2` |
| `--syn-escape` | `#cf8e6d` | `#0037a6` |
| `--syn-regexp` | `#42c3d4` | `#264eff` |
| `--syn-tag` | `#d5b778` | `#0033b3` |
| `--syn-attribute` | `#bcbec4` | `#174ad4` |
| `--syn-todo` | `#8bb33d` | `#008dde` |
| `--syn-link` | `#548af7` | `#006dcc` |
| `--syn-markup-heading` | `#cf8e6d` | `#871094` |
| `--syn-markup-code` | `#6aab73` | `#067d17` |
| `--syn-invalid` | `#f75464` | `#f50000` |
| `--syn-inline-value` | `#868a91` | `#8c8c8c` |
### Editor (editor scheme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--fg-editor` | `#bcbec4` | `#080808` |
| `--caret` | `#ced0d6` | `#000000` |
| `--fg-line-number` | `#4b5059` | `#aeb3c2` |
| `--fg-line-number-current` | `#a1a3ab` | `#767a8a` |
| `--border-gutter` | `#26282c` | `#ebecf0` |
| `--border-indent-guide` | `#323438` | `#ebecf0` |
| `--border-indent-guide-active` | `#4e5157` | `#aeb3c2` |
| `--border-right-margin` | `#323438` | `#ebecf0` |
| `--bg-editor-selection` | `#214283` | `#a6d2ff` |
| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
| `--matched-brace-weight` | `700` | `normal` |
| `--bg-matched-tag` | `#3f4045` | `#c9ecec` |
| `--bg-fold-placeholder` | `#393b40` | `#e9f5e6` |
| `--fg-fold-placeholder` | `#868991` | `#414d41` |
| `--fg-fold-marker` | `#6f737a` | `#8b8e94` |
| `--fg-whitespace` | `#6f737a` | `#b5b7bd` |
| `--fg-unused` | `#6f737a` | `#8c8c8c` |
| `--bg-identifier-under-caret` | `#373b39` | `#edebfc` |
| `--bg-identifier-under-caret-write` | `#402f33` | `#fce8f4` |
| `--fg-inlay` | `#868a91` | `#73767c` |
| `--bg-inlay` | `#393b40` | `#00000012` |
| `--wave-error` | `#fa6675` | `#f50000` |
| `--wave-warning` | `#f2c55c` | `#f2bf57` |
| `--editor-error-decoration` | `underline wavy #fa6675` | `underline wavy #f50000` |
| `--editor-warning-decoration` | `underline wavy #f2c55c` | `none` |
| `--editor-warning-background` | `transparent` | `#fcf4d4` |
| `--bg-breakpoint-line` | `#40252b` | `#faeae6` |
| `--fg-completion-match` | `#538af9` | `#2f5eb9` |
| `--bg-editor-inline` | `#212326` | `#f7f8f9` |
| `--bg-editor-banner` | `#233558` | `#f7f8ff` |
| `--fg-weak-warning` | `#6f737a` | `#a8adbd` |
| `--fg-bulb` | `#f2c55c` | `#ffaf0f` |
### ANSI palette (editor scheme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--ansi-black` | `#000000` | `#000000` |
| `--ansi-red` | `#f0524f` | `#e5372a` |
| `--ansi-green` | `#5c962c` | `#367a29` |
| `--ansi-yellow` | `#a68a0d` | `#ad8d2b` |
| `--ansi-blue` | `#3993d4` | `#3866d1` |
| `--ansi-magenta` | `#a771bf` | `#a33cae` |
| `--ansi-cyan` | `#00a3a3` | `#3c8b8d` |
| `--ansi-white` | `#808080` | `#8b8e94` |
| `--ansi-bright-black` | `#595959` | `#73767c` |
| `--ansi-bright-red` | `#ff4050` | `#f05046` |
| `--ansi-bright-green` | `#4fc414` | `#2e9b3f` |
| `--ansi-bright-yellow` | `#e5bf00` | `#b58e00` |
| `--ansi-bright-blue` | `#1fb0ff` | `#3d82f0` |
| `--ansi-bright-magenta` | `#ed7eed` | `#c04bcf` |
| `--ansi-bright-cyan` | `#00e5e5` | `#1b9ea3` |
| `--ansi-bright-white` | `#ffffff` | `#5f6269` |
### Shadows and window controls (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--shadow-popup` | `0 6px 20px rgba(0, 0, 0, 0.45)` | `0 6px 20px rgba(0, 0, 0, 0.12)` |
| `--shadow-dialog` | `0 0 0 0.5px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.35), 0 24px 72px rgba(0, 0, 0, 0.45)` | `0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 10px 24px rgba(0, 0, 0, 0.1), 0 24px 72px rgba(0, 0, 0, 0.2)` |
| `--traffic-close` | `#ff5f57` | `#ff5f57` |
| `--traffic-close-border` | `#e0443e` | `#e0443e` |
| `--traffic-close-pressed` | `#bf4942` | `#bf4942` |
| `--traffic-glyph` | `#4d0000` | `#4d0000` |
| `--traffic-off` | `#4b4c4f` | `#dddddd` |
| `--traffic-off-border` | `#404144` | `#c8c8c8` |
### Additive (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--bg-tooltip` | `#33353b` | `#ffffff` |
| `--fg-tooltip` | `#d1d3d9` | `#000000` |
| `--border-tooltip` | `#33353b` | `#d1d3d9` |
| `--bg-control` | `#26282c` | `#ffffff` |
| `--bg-control-hover` | `#33353b` | `#f7f8f9` |
| `--bg-active` | `#40434a` | `#dddfe4` |
| `--bg-scrim` | `rgba(0, 0, 0, 0.45)` | `rgba(0, 0, 0, 0.16)` |
| `--fg-on-accent` | `#ffffff` | `#ffffff` |
| `--shadow-tooltip` | `0 4px 12px rgba(0, 0, 0, 0.4)` | `0 4px 12px rgba(0, 0, 0, 0.12)` |
| `--shadow-balloon` | `0 8px 24px rgba(0, 0, 0, 0.45)` | `0 8px 24px rgba(0, 0, 0, 0.14)` |
### Islands contract (UI theme)
| Token | Islands Dark | Islands Light |
|---|---|---|
| `--bg-frame` | `#26282c` | `#e9eaee` |
| `--bg-island` | `#191a1c` | `#ffffff` |
| `--bg-stripe-hover` | `#ffffff17` | `#00000012` |
| `--bg-stripe-selected` | `#ffffff29` | `#00000020` |
| `--fg-stripe-selected-active` | `#ffffff` | `#ffffff` |
| `--fg-stripe-label` | `#9fa2a8` | `#5f6269` |
| `--bg-tab-selected` | `#233558` | `#e3ebfe` |
| `--border-tab-selected` | `#2e4d89` | `#a7c5ff` |
| `--bg-tab-selected-inactive` | `#26282c` | `#e9eaee` |
| `--border-tab-selected-inactive` | `#40434a` | `#d1d3d9` |
| `--bg-tab-hover` | `#ffffff17` | `#00000012` |
| `--border-editor-tabs` | `#26282c` | `#e9eaee` |
| `--bg-tree-selected` | `#2a4371` | `#d0dffe` |
| `--bg-tree-selected-inactive` | `#33353b` | `#e9eaee` |
| `--bg-tree-hover` | `#ffffff10` | `#00000008` |
| `--bg-toolbar-hover` | `#ffffff17` | `#00000012` |
| `--bg-toolbar-pressed` | `#ffffff29` | `#00000020` |
| `--bg-toolbar-selected` | `#2e4d89` | `#d0dffe` |
| `--bg-toolbar-selected-hover` | `#2f5eb9` | `#bdd3ff` |
| `--bg-button-primary-hover` | `#2f5eb9` | `#2f5eb9` |
| `--fg-button-primary` | `#ffffff` | `#ffffff` |
| `--bg-button` | `transparent` | `#ffffff` |
| `--border-button` | `#40434a` | `#d1d3d9` |
| `--bg-field` | `#191a1c` | `#ffffff` |
| `--bg-checkbox` | `#191a1c` | `#ffffff` |
| `--border-checkbox` | `#5f6269` | `#a8adbd` |
| `--bg-toggle-on` | `#338555` | `#4e9d6c` |
| `--fg-toggle-on` | `#ffffff` | `#ffffff` |
| `--bg-toggle-off` | `#191a1c` | `#f7f8f9` |
| `--fg-toggle-off` | `#73767c` | `#73767c` |
| `--border-toggle` | `#5f6269` | `#c3c5cb` |
| `--bg-toggle-knob` | `#5f6269` | `#c3c5cb` |
| `--focus-ring-soft` | `#3d6185` | `#97c3f3` |
| `--scrollbar-thumb` | `#ffffff33` | `#00000033` |
| `--scrollbar-thumb-hover` | `#ffffff59` | `#00000080` |
| `--bg-search-match` | `#ba9752` | `#fee6b1` |
| `--bg-search-match-current` | `#f2c55c` | `#fcd47e` |
| `--fg-search-match` | `#191a1c` | `#000000` |
| `--bg-dialog` | `#191a1c` | `#f7f8f9` |
| `--bg-popup-inline` | `#26282c` | `#f7f8f9` |
| `--bg-popup-advertiser` | `#26282c` | `#f7f8f9` |
| `--bg-feedback-info` | `#233558` | `#f7f8ff` |
| `--border-feedback-info` | `#2e4d89` | `#bdd3ff` |
| `--bg-feedback-error` | `#56272b` | `#fff6f5` |
| `--border-feedback-error` | `#80383e` | `#ffc4c5` |
| `--bg-feedback-warning` | `#44321d` | `#fff6e9` |
| `--border-feedback-warning` | `#694820` | `#f4cd9a` |
| `--bg-feedback-success` | `#203b2a` | `#f5faf3` |
| `--border-feedback-success` | `#29583c` | `#bbdbc2` |
| `--bg-counter` | `#73767c` | `#8b8e94` |
| `--fg-counter` | `#000000` | `#ffffff` |
| `--bg-progress-track` | `#40434a` | `#dddfe4` |
<!-- END GENERATED TOKEN REFERENCE -->
+3 -1
View File
@@ -14,8 +14,10 @@
"test:watch": "vitest",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit",
"typecheck": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit && tsc -p perf --noEmit && tsc -p scripts --noEmit",
"gen:bindings": "cargo test -p openidea --test export_bindings",
"gen:theme-docs": "node scripts/theme/gen-docs.ts",
"theme:import-intellij": "node scripts/theme/import-intellij-theme.ts",
"dev:app": "tauri dev --config src-tauri/tauri.dev.conf.json",
"e2e": "playwright test",
"perf": "node perf/run.ts",
+8 -2
View File
@@ -179,8 +179,14 @@ never affected.
Tokens are the CSS variables of `src/styles/tokens.css` (`--bg-*`, `--fg-*`,
`--border*`, `--accent`, `--syn-*`, `--vcs-*`, `--gutter-*`, …). Unlisted tokens
keep the built-in values. Values must be plain CSS values (no `url()`, `;`,
braces). Pick a theme in Settings › Plugins › *Apply*.
keep the values of the built-in base named by `kind`. Values must be plain CSS
values (no `url()`, `;`, braces); `//` and `/* */` comments are allowed. A
manifest theme entry may add `editor-scheme = "themes/x.scheme.json"` (editor
colours, merged over the theme). Pick a theme in Settings › Plugins › *Apply*.
Start from [`templates/theme-plugin`](../templates/theme-plugin); the full guide,
token reference and IntelliJ / VS Code porting tables are in
[`docs/plugins/themes.md`](../docs/plugins/themes.md).
## 4. Languages
+14
View File
@@ -0,0 +1,14 @@
/**
* Regenerates the token reference in docs/plugins/themes.md from
* src/styles/tokens.css (`pnpm gen:theme-docs`). scripts/theme/theme.test.ts
* fails when the checked-in reference is stale.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { referenceMarkdown, themeTokenGroups, withReference } from "./tokens.ts";
const DOC = "docs/plugins/themes.md";
const groups = themeTokenGroups(readFileSync("src/styles/tokens.css", "utf8"));
const doc = readFileSync(DOC, "utf8");
const next = withReference(doc, referenceMarkdown(groups));
if (next !== doc) writeFileSync(DOC, next);
console.log(next === doc ? `${DOC} is up to date` : `updated ${DOC}`);
+52
View File
@@ -0,0 +1,52 @@
/**
* Converts an IntelliJ theme into an OpenIDEA theme plugin package.
*
* pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]
*
* Writes <dir>/plugin.toml and <dir>/themes/<id>.theme.json (+ .scheme.json),
* ready for Settings › Plugins › Install Plugin from Disk…. Only port themes
* whose licence allows it, and credit the original author.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join } from "node:path";
import { parseArgs } from "node:util";
import { convertIntellijTheme, pluginManifest } from "./intellij.ts";
const { values, positionals } = parseArgs({
allowPositionals: true,
options: { out: { type: "string" }, id: { type: "string" }, author: { type: "string" } },
});
const [themePath, iclsPath] = positionals;
if (!themePath || !values.out) {
console.error(
"usage: pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir> [--id <id>] [--author <name>]",
);
process.exit(2);
}
const converted = convertIntellijTheme(
readFileSync(themePath, "utf8"),
iclsPath ? readFileSync(iclsPath, "utf8") : undefined,
values.id,
);
const { theme, scheme } = converted;
const source = [basename(themePath), iclsPath && basename(iclsPath)].filter(Boolean).join(" + ");
const header = (what: string) => `// ${theme.name}: ${what}, converted from ${source}.\n`;
mkdirSync(join(values.out, "themes"), { recursive: true });
writeFileSync(join(values.out, "plugin.toml"), pluginManifest(converted, values.author ?? "Unknown", source));
writeFileSync(
join(values.out, "themes", `${theme.id}.theme.json`),
header("UI theme") + JSON.stringify(theme, null, 2) + "\n",
);
if (scheme) {
writeFileSync(
join(values.out, "themes", `${theme.id}.scheme.json`),
header("editor colour scheme") + JSON.stringify(scheme, null, 2) + "\n",
);
}
console.log(
`${theme.name} (${theme.kind}): ${Object.keys(theme.tokens).length} UI tokens` +
(scheme ? `, ${Object.keys(scheme.tokens).length} editor tokens` : "") +
` → ${values.out}\nUnmapped tokens keep the built-in ${theme.kind} values; see docs/plugins/themes.md to fill them in.`,
);
+318
View File
@@ -0,0 +1,318 @@
/**
* Converts an IntelliJ theme (`.theme.json`, optionally with its editor scheme
* `.icls`) into an OpenIDEA theme + editor scheme. The mapping covers the
* main keys only; anything unmapped keeps the built-in value of the base
* (`kind`). Pure functions, no dependencies: see import-intellij-theme.ts
* for the command line.
*/
import { stripJsonComments } from "./tokens.ts";
/** OpenIDEA token ← IntelliJ `ui` keys, first present wins. `*.prop` wildcards apply too. */
export const UI_KEYS: Record<string, string[]> = {
"--bg-app": ["Panel.background"],
"--bg-frame": ["MainWindow.background", "MainToolbar.background", "Panel.background"],
"--bg-island": ["ToolWindow.background", "Panel.background"],
"--bg-elev": ["ToolWindow.background", "SidePanel.background"],
"--bg-popup": ["Popup.background", "PopupMenu.background"],
"--bg-backdrop": ["Popup.background"],
"--bg-hover": ["List.hoverBackground", "Tree.hoverBackground"],
"--bg-selected": ["List.selectionBackground", "Tree.selectionBackground"],
"--bg-selected-strong": ["List.selectionBackground"],
"--bg-tree-selected": ["Tree.selectionBackground", "List.selectionBackground"],
"--bg-tree-selected-inactive": ["Tree.selectionInactiveBackground", "List.selectionInactiveBackground"],
"--bg-input": ["TextField.background"],
"--bg-field": ["TextField.background"],
"--bg-dialog": ["Dialog.background", "Panel.background"],
"--border": ["Borders.color"],
"--border-popup": ["Popup.borderColor", "Popup.Border.color"],
"--border-strong": ["Component.borderColor", "Borders.ContrastBorderColor"],
"--border-window": ["Borders.ContrastBorderColor", "Component.borderColor"],
"--fg": ["Label.foreground"],
"--fg-emphasis": ["Label.foreground"],
"--fg-strong": ["Label.selectedForeground", "Label.foreground"],
"--fg-secondary": ["Label.infoForeground", "Component.infoForeground"],
"--fg-muted": ["Label.infoForeground", "Component.infoForeground"],
"--fg-subtle": ["Label.disabledForeground"],
"--fg-faint": ["Label.disabledForeground"],
"--accent": ["Button.default.startBackground", "Button.default.background", "Component.focusColor"],
"--fg-on-accent": ["Button.default.foreground"],
"--fg-button-primary": ["Button.default.foreground"],
"--focus-ring-soft": ["Component.focusColor"],
"--link": ["Link.activeForeground", "Hyperlink.linkColor"],
"--link-hover": ["Link.hoverForeground", "Link.activeForeground"],
"--danger": ["Component.errorFocusColor"],
"--warning": ["Component.warningFocusColor"],
"--bg-tooltip": ["ToolTip.background"],
"--fg-tooltip": ["ToolTip.foreground"],
"--border-tooltip": ["ToolTip.borderColor"],
"--bg-button": ["Button.startBackground", "Button.background"],
"--border-button": ["Button.startBorderColor", "Button.borderColor"],
"--bg-checkbox": ["Checkbox.Background.Default"],
"--border-checkbox": ["Checkbox.Border.Default"],
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
"--border-tab-selected": ["EditorTabs.underlineColor"],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
"--bg-toolbar-pressed": ["ActionButton.pressedBackground"],
"--bg-stripe-hover": ["ToolWindow.Button.hoverBackground"],
"--bg-stripe-selected": ["ToolWindow.Button.selectedBackground"],
"--scrollbar-thumb": ["ScrollBar.Mac.thumbColor", "ScrollBar.thumbColor"],
"--scrollbar-thumb-hover": ["ScrollBar.Mac.hoverThumbColor", "ScrollBar.hoverThumbColor"],
"--bg-progress-track": ["ProgressBar.trackColor"],
"--bg-counter": ["Counter.background"],
"--fg-counter": ["Counter.foreground"],
"--bg-search-match": ["SearchMatch.startBackground", "SearchMatch.endBackground"],
"--bg-toggle-on": ["ToggleButton.onBackground"],
"--fg-toggle-on": ["ToggleButton.onForeground"],
"--bg-toggle-off": ["ToggleButton.offBackground"],
"--fg-toggle-off": ["ToggleButton.offForeground"],
"--border-toggle": ["ToggleButton.borderColor"],
"--bg-toggle-knob": ["ToggleButton.buttonColor"],
"--bg-feedback-info": ["Banner.infoBackground"],
"--border-feedback-info": ["Banner.infoBorderColor"],
"--bg-feedback-error": ["Banner.errorBackground"],
"--border-feedback-error": ["Banner.errorBorderColor"],
"--bg-feedback-warning": ["Banner.warningBackground"],
"--border-feedback-warning": ["Banner.warningBorderColor"],
"--bg-feedback-success": ["Banner.successBackground"],
"--border-feedback-success": ["Banner.successBorderColor"],
};
/** `.icls` `<colors>` options → tokens. */
export const ICLS_COLORS: Record<string, string[]> = {
CARET_COLOR: ["--caret"],
CARET_ROW_COLOR: ["--bg-current-line"],
SELECTION_BACKGROUND: ["--bg-editor-selection"],
LINE_NUMBERS_COLOR: ["--fg-line-number"],
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
GUTTER_BACKGROUND: ["--bg-editor-gutter"],
INDENT_GUIDE: ["--border-indent-guide"],
SELECTED_INDENT_GUIDE: ["--border-indent-guide-active"],
RIGHT_MARGIN_COLOR: ["--border-right-margin"],
WHITESPACES: ["--fg-whitespace"],
METHOD_SEPARATORS_COLOR: ["--border-method-separator"],
FILESTATUS_MODIFIED: ["--vcs-modified"],
FILESTATUS_ADDED: ["--vcs-added"],
FILESTATUS_DELETED: ["--vcs-deleted"],
FILESTATUS_UNKNOWN: ["--vcs-untracked"],
FILESTATUS_IDEA_FILESTATUS_IGNORED: ["--vcs-ignored"],
FILESTATUS_IDEA_FILESTATUS_MERGED_WITH_CONFLICTS: ["--vcs-conflict"],
MODIFIED_LINES_COLOR: ["--gutter-modified"],
ADDED_LINES_COLOR: ["--gutter-added"],
DELETED_LINES_COLOR: ["--gutter-deleted"],
};
type Part = "FOREGROUND" | "BACKGROUND" | "EFFECT_COLOR";
/** `.icls` `<attributes>` options → [part, token] pairs. */
export const ICLS_ATTRIBUTES: Record<string, Array<[Part, string]>> = {
TEXT: [
["FOREGROUND", "--fg-editor"],
["BACKGROUND", "--bg-editor"],
],
DEFAULT_KEYWORD: [["FOREGROUND", "--syn-keyword"]],
DEFAULT_STRING: [["FOREGROUND", "--syn-string"]],
DEFAULT_NUMBER: [["FOREGROUND", "--syn-number"]],
DEFAULT_LINE_COMMENT: [["FOREGROUND", "--syn-comment"]],
DEFAULT_DOC_COMMENT: [["FOREGROUND", "--syn-doc-comment"]],
DEFAULT_FUNCTION_DECLARATION: [["FOREGROUND", "--syn-function"]],
DEFAULT_FUNCTION_CALL: [["FOREGROUND", "--syn-function-call"]],
DEFAULT_CLASS_NAME: [["FOREGROUND", "--syn-type"]],
DEFAULT_CONSTANT: [["FOREGROUND", "--syn-constant"]],
DEFAULT_INSTANCE_FIELD: [["FOREGROUND", "--syn-property"]],
DEFAULT_IDENTIFIER: [["FOREGROUND", "--syn-variable"]],
DEFAULT_LOCAL_VARIABLE: [["FOREGROUND", "--syn-variable"]],
DEFAULT_PARAMETER: [["FOREGROUND", "--syn-param"]],
DEFAULT_PREDEFINED_SYMBOL: [["FOREGROUND", "--syn-builtin"]],
DEFAULT_METADATA: [
["FOREGROUND", "--syn-annotation"],
["FOREGROUND", "--syn-decorator"],
],
DEFAULT_OPERATION_SIGN: [["FOREGROUND", "--syn-punct"]],
DEFAULT_VALID_STRING_ESCAPE: [["FOREGROUND", "--syn-escape"]],
DEFAULT_TAG: [["FOREGROUND", "--syn-tag"]],
DEFAULT_ATTRIBUTE: [["FOREGROUND", "--syn-attribute"]],
TODO_DEFAULT_ATTRIBUTES: [["FOREGROUND", "--syn-todo"]],
BAD_CHARACTER: [["FOREGROUND", "--syn-invalid"]],
ERRORS_ATTRIBUTES: [["EFFECT_COLOR", "--wave-error"]],
WARNING_ATTRIBUTES: [["EFFECT_COLOR", "--wave-warning"]],
NOT_USED_ELEMENT_ATTRIBUTES: [["FOREGROUND", "--fg-unused"]],
MATCHED_BRACE_ATTRIBUTES: [["BACKGROUND", "--bg-matched-brace"]],
SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-search-match"]],
TEXT_SEARCH_RESULT_ATTRIBUTES: [["BACKGROUND", "--bg-text-search-result"]],
IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret"]],
WRITE_IDENTIFIER_UNDER_CARET_ATTRIBUTES: [["BACKGROUND", "--bg-identifier-under-caret-write"]],
FOLDED_TEXT_ATTRIBUTES: [
["FOREGROUND", "--fg-fold-placeholder"],
["BACKGROUND", "--bg-fold-placeholder"],
],
INLINE_PARAMETER_HINT: [
["FOREGROUND", "--fg-inlay"],
["BACKGROUND", "--bg-inlay"],
],
BREAKPOINT_ATTRIBUTES: [["BACKGROUND", "--bg-breakpoint-line"]],
DEBUGGER_INLINED_VALUES: [["FOREGROUND", "--syn-inline-value"]],
"PY.SELF_PARAMETER": [["FOREGROUND", "--syn-self"]],
"PY.KEYWORD_ARGUMENT": [["FOREGROUND", "--syn-keyword-arg"]],
"PY.DOC_COMMENT": [["FOREGROUND", "--syn-docstring"]],
"PY.BUILTIN_NAME": [["FOREGROUND", "--syn-predefined"]],
MARKDOWN_HEADER_LEVEL_1: [["FOREGROUND", "--syn-markup-heading"]],
MARKDOWN_CODE_SPAN: [["FOREGROUND", "--syn-markup-code"]],
MARKDOWN_LINK_TEXT: [["FOREGROUND", "--syn-link"]],
CONSOLE_BLACK_OUTPUT: [["FOREGROUND", "--ansi-black"]],
CONSOLE_RED_OUTPUT: [["FOREGROUND", "--ansi-red"]],
CONSOLE_GREEN_OUTPUT: [["FOREGROUND", "--ansi-green"]],
CONSOLE_YELLOW_OUTPUT: [["FOREGROUND", "--ansi-yellow"]],
CONSOLE_BLUE_OUTPUT: [["FOREGROUND", "--ansi-blue"]],
CONSOLE_MAGENTA_OUTPUT: [["FOREGROUND", "--ansi-magenta"]],
CONSOLE_CYAN_OUTPUT: [["FOREGROUND", "--ansi-cyan"]],
CONSOLE_GRAY_OUTPUT: [["FOREGROUND", "--ansi-white"]],
CONSOLE_DARKGRAY_OUTPUT: [["FOREGROUND", "--ansi-bright-black"]],
CONSOLE_RED_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-red"]],
CONSOLE_GREEN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-green"]],
CONSOLE_YELLOW_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-yellow"]],
CONSOLE_BLUE_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-blue"]],
CONSOLE_MAGENTA_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-magenta"]],
CONSOLE_CYAN_BRIGHT_OUTPUT: [["FOREGROUND", "--ansi-bright-cyan"]],
CONSOLE_WHITE_OUTPUT: [["FOREGROUND", "--ansi-bright-white"]],
};
export interface ConvertedTheme {
theme: { id: string; name: string; kind: "dark" | "light"; tokens: Record<string, string> };
scheme: { name: string; tokens: Record<string, string> } | null;
}
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
/** `"Button": {"default": {"startBackground": x}}` → `Button.default.startBackground`; `"*": {...}` → `*.prop`. */
function flatten(obj: { [k: string]: Json }, prefix = "", out = new Map<string, string>()): Map<string, string> {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === "string") out.set(key, v);
else if (v && typeof v === "object" && !Array.isArray(v)) flatten(v, key, out);
}
return out;
}
const HEX = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
/** IntelliJ colour (`#rrggbb`, `#rrggbbaa`, or a named colour from `colors`) → CSS hex. */
function resolveColor(value: string, named: Map<string, string>, depth = 0): string | null {
const m = HEX.exec(value.trim());
if (m) return `#${m[1]!.toLowerCase()}`;
const ref = named.get(value);
return ref !== undefined && depth < 8 ? resolveColor(ref, named, depth + 1) : null;
}
/** `.icls` hex: RGB without `#`, leading zeros dropped (`"0"` is black, `"ff"` is #0000ff). */
function iclsColor(value: string): string | null {
const v = value.trim().toLowerCase();
if (!/^[0-9a-f]{1,8}$/.test(v)) return null;
return `#${v.length <= 6 ? v.padStart(6, "0") : v.padStart(8, "0")}`;
}
const attr = (tag: string, name: string) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1];
/** Parses the colours and attributes of an `.icls` file into tokens. */
export function convertIcls(xml: string): { name: string; dark: boolean | null; tokens: Record<string, string> } {
const tokens: Record<string, string> = {};
const set = (names: string[], color: string | null) => {
if (color) for (const n of names) tokens[n] ??= color;
};
const schemeTag = /<scheme\b[^>]*>/.exec(xml)?.[0] ?? "";
const name = attr(schemeTag, "name") ?? "Imported";
const parent = attr(schemeTag, "parent_scheme") ?? "";
const dark = parent ? /darcula|dark/i.test(parent) : null;
const colors = /<colors>([\s\S]*?)<\/colors>/.exec(xml)?.[1] ?? "";
for (const m of colors.matchAll(/<option\b[^>]*\/>/g)) {
const key = attr(m[0], "name");
const value = attr(m[0], "value");
if (key && value !== undefined && ICLS_COLORS[key]) set(ICLS_COLORS[key], iclsColor(value));
}
const attributes = /<attributes>([\s\S]*?)<\/attributes>/.exec(xml)?.[1] ?? "";
for (const m of attributes.matchAll(/<option name="([^"]+)"[^>]*>\s*<value>([\s\S]*?)<\/value>\s*<\/option>/g)) {
const map = ICLS_ATTRIBUTES[m[1]!];
if (!map) continue;
const parts = new Map<string, string>();
for (const o of m[2]!.matchAll(/<option\b[^>]*\/>/g)) {
const k = attr(o[0], "name");
const v = attr(o[0], "value");
if (k && v !== undefined) parts.set(k, v);
}
for (const [part, token] of map) set([token], iclsColor(parts.get(part) ?? ""));
// FONT_TYPE: 1 bold, 2 italic, 3 bold italic.
if (m[1] === "DEFAULT_LINE_COMMENT" && parts.has("FONT_TYPE")) {
tokens["--syn-comment-style"] = Number(parts.get("FONT_TYPE")) & 2 ? "italic" : "normal";
}
}
if (tokens["--wave-error"]) tokens["--editor-error-decoration"] = `underline wavy ${tokens["--wave-error"]}`;
if (tokens["--bg-editor"]) tokens["--bg-editor-gutter"] ??= tokens["--bg-editor"];
return { name, dark, tokens };
}
export function slug(name: string): string {
return (
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "imported"
);
}
/** Converts an IntelliJ `.theme.json` (and optionally its `.icls`) into OpenIDEA theme files. */
export function convertIntellijTheme(themeJson: string, icls?: string, id?: string): ConvertedTheme {
const src = JSON.parse(stripJsonComments(themeJson)) as { [k: string]: Json };
const name = typeof src.name === "string" ? src.name : "Imported Theme";
const named = new Map<string, string>();
if (src.colors && typeof src.colors === "object" && !Array.isArray(src.colors)) {
for (const [k, v] of Object.entries(src.colors)) if (typeof v === "string") named.set(k, v);
}
const ui =
src.ui && typeof src.ui === "object" && !Array.isArray(src.ui) ? flatten(src.ui) : new Map<string, string>();
const lookup = (key: string): string | null => {
const v = ui.get(key) ?? ui.get(`*.${key.slice(key.lastIndexOf(".") + 1)}`);
return v === undefined ? null : resolveColor(v, named);
};
const tokens: Record<string, string> = {};
for (const [token, keys] of Object.entries(UI_KEYS)) {
for (const key of keys) {
const color = lookup(key);
if (color) {
tokens[token] = color;
break;
}
}
}
const scheme = icls ? convertIcls(icls) : null;
const dark = typeof src.dark === "boolean" ? src.dark : (scheme?.dark ?? true);
// The editor surface sits on an island in OpenIDEA: keep it on the editor background.
if (scheme?.tokens["--bg-editor"] && !tokens["--bg-island"]) tokens["--bg-island"] = scheme.tokens["--bg-editor"];
return {
theme: { id: id ?? slug(name), name, kind: dark ? "dark" : "light", tokens },
scheme: scheme ? { name: scheme.name, tokens: scheme.tokens } : null,
};
}
/** `plugin.toml` for a converted theme. */
export function pluginManifest(c: ConvertedTheme, author: string, sourceName: string): string {
const q = (s: string) => JSON.stringify(s);
return [
`# Converted from ${sourceName} by scripts/theme/import-intellij-theme.ts.`,
`id = ${q(`theme-${c.theme.id}`)}`,
`name = ${q(`${c.theme.name} Theme`)}`,
`version = "1.0.0"`,
`description = ${q(`${c.theme.name}, converted from an IntelliJ theme.`)}`,
`authors = [${q(author)}]`,
`openidea-api = "0.1"`,
"",
"[[contributes.themes]]",
`path = ${q(`themes/${c.theme.id}.theme.json`)}`,
...(c.scheme ? [`editor-scheme = ${q(`themes/${c.theme.id}.scheme.json`)}`] : []),
"",
].join("\n");
}
+182
View File
@@ -0,0 +1,182 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
convertIcls,
convertIntellijTheme,
ICLS_ATTRIBUTES,
ICLS_COLORS,
pluginManifest,
UI_KEYS,
} from "./intellij.ts";
import { referenceMarkdown, stripJsonComments, themeTokenGroups, tokenSets, withReference } from "./tokens.ts";
const read = (p: string) => readFileSync(p, "utf8");
const css = read("src/styles/tokens.css");
const groups = themeTokenGroups(css);
const sets = tokenSets(groups);
const TEMPLATE = "templates/theme-plugin";
const themeSrc = read(`${TEMPLATE}/themes/harbor-night.theme.json`);
const schemeSrc = read(`${TEMPLATE}/themes/harbor-night.scheme.json`);
const theme = JSON.parse(stripJsonComments(themeSrc)) as { id: string; kind: string; tokens: Record<string, string> };
const scheme = JSON.parse(stripJsonComments(schemeSrc)) as { tokens: Record<string, string> };
const sorted = (xs: Iterable<string>) => [...xs].sort();
describe("theme token reference", () => {
it("reads every variable of the Islands Light block, grouped", () => {
const lightBlock = /:root\[data-theme="light"\] \{([\s\S]*?)\n\}/.exec(css)![1]!;
const declared = new Set([...lightBlock.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
expect(sorted([...sets.ui, ...sets.scheme])).toEqual(sorted(declared));
expect(groups.map((g) => g.title)).toEqual(
expect.arrayContaining(["surfaces", "syntax", "editor", "ANSI palette"]),
);
expect(sets.scheme.every((t) => !sets.ui.includes(t))).toBe(true);
});
it("requires a section comment for every chunk of the light block", () => {
const bad = ':root { --a: #000; }\n:root[data-theme="light"] {\n /* ok */\n --a: #fff;\n\n --b: #fff;\n}';
expect(() => themeTokenGroups(bad)).toThrow(/--b .*section comment/);
});
it("is current in docs/plugins/themes.md (run pnpm gen:theme-docs)", () => {
const doc = read("docs/plugins/themes.md");
expect(withReference(doc, referenceMarkdown(groups))).toBe(doc);
});
});
describe("theme plugin template", () => {
it("sets every UI token in the theme file and every editor token in the scheme file", () => {
// A failure here means tokens.css gained (or lost) a themeable token:
// add it to templates/theme-plugin/themes/*.json, then run pnpm gen:theme-docs.
expect(sorted(Object.keys(theme.tokens))).toEqual(sorted(sets.ui));
expect(sorted(Object.keys(scheme.tokens))).toEqual(sorted(sets.scheme));
});
it("groups tokens under the section comments of tokens.css", () => {
for (const g of groups) {
const src = g.scheme ? schemeSrc : themeSrc;
const heading = `// ${g.title.charAt(0).toUpperCase()}${g.title.slice(1)}`;
expect(src, heading).toContain(heading);
const at = src.indexOf(heading);
for (const t of g.tokens) expect(src.indexOf(`"${t.name}"`), t.name).toBeGreaterThan(at);
}
});
it("uses only values the plugin host accepts", () => {
for (const [k, v] of Object.entries({ ...theme.tokens, ...scheme.tokens })) {
expect(/[;{}<>\\\n]|url\(|image\(/i.test(v), `${k}: ${v}`).toBe(false);
}
expect(theme.kind).toBe("dark");
});
it("is referenced by its manifest", () => {
const manifest = read(`${TEMPLATE}/plugin.toml`);
expect(manifest).toContain('path = "themes/harbor-night.theme.json"');
expect(manifest).toContain('editor-scheme = "themes/harbor-night.scheme.json"');
expect(manifest).not.toMatch(/^wasm\s*=/m);
});
});
describe("IntelliJ theme import", () => {
const themeJson = `{
// comment
"name": "Deep Sea",
"dark": true,
"author": "Someone",
"colors": { "base": "#101820", "accent": "#33AAFF" },
"ui": {
"*": { "background": "base", "foreground": "#c0c8d0", "selectionBackground": "#203040" },
"Button": { "default": { "startBackground": "accent" } },
"Popup.background": "#181f28",
"Label.infoForeground": "#80888F80",
"Tree": { "rowHeight": 24 }
}
}`;
const icls = `<scheme name="Deep Sea" version="142" parent_scheme="Darcula">
<colors>
<option name="CARET_COLOR" value="ffcc00" />
<option name="LINE_NUMBERS_COLOR" value="0" />
<option name="UNKNOWN_KEY" value="123456" />
</colors>
<attributes>
<option name="DEFAULT_KEYWORD">
<value>
<option name="FOREGROUND" value="cc7832" />
<option name="FONT_TYPE" value="1" />
</value>
</option>
<option name="DEFAULT_LINE_COMMENT">
<value>
<option name="FOREGROUND" value="808080" />
<option name="FONT_TYPE" value="2" />
</value>
</option>
<option name="TEXT">
<value>
<option name="FOREGROUND" value="a9b7c6" />
<option name="BACKGROUND" value="2b2b2b" />
</value>
</option>
<option name="ERRORS_ATTRIBUTES">
<value>
<option name="EFFECT_COLOR" value="ff0000" />
<option name="EFFECT_TYPE" value="2" />
</value>
</option>
<option name="DEFAULT_CONSTANT" baseAttributes="DEFAULT_IDENTIFIER" />
</attributes>
</scheme>`;
it("maps ui keys, named colours and wildcards", () => {
const { theme: t, scheme: s } = convertIntellijTheme(themeJson);
expect(t).toMatchObject({ id: "deep-sea", name: "Deep Sea", kind: "dark" });
expect(t.tokens["--bg-app"]).toBe("#101820"); // *.background → named colour
expect(t.tokens["--accent"]).toBe("#33aaff");
expect(t.tokens["--bg-popup"]).toBe("#181f28");
expect(t.tokens["--bg-selected"]).toBe("#203040");
expect(t.tokens["--fg-muted"]).toBe("#80888f80");
expect(t.tokens["--fg"]).toBe("#c0c8d0");
expect(s).toBeNull();
});
it("only targets tokens that exist in tokens.css", () => {
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
// Read with a fallback by the editor (no tokens.css default), still themeable.
declared.add("--border-method-separator");
const targets = [
...Object.keys(UI_KEYS),
...Object.values(ICLS_COLORS).flat(),
...Object.values(ICLS_ATTRIBUTES).flatMap((parts) => parts.map(([, token]) => token)),
];
expect(targets.filter((t) => !declared.has(t))).toEqual([]);
});
it("maps .icls colours and attributes", () => {
const s = convertIcls(icls);
expect(s.name).toBe("Deep Sea");
expect(s.dark).toBe(true);
expect(s.tokens).toMatchObject({
"--caret": "#ffcc00",
"--fg-line-number": "#000000",
"--syn-keyword": "#cc7832",
"--syn-comment": "#808080",
"--syn-comment-style": "italic",
"--fg-editor": "#a9b7c6",
"--bg-editor": "#2b2b2b",
"--bg-editor-gutter": "#2b2b2b",
"--wave-error": "#ff0000",
"--editor-error-decoration": "underline wavy #ff0000",
});
expect(s.tokens["--syn-constant"]).toBeUndefined();
});
it("produces an installable package", () => {
const c = convertIntellijTheme(themeJson.replace('"dark": true,', ""), icls.replace("Darcula", "Default"), "sea");
expect(c.theme.kind).toBe("light");
expect(c.theme.id).toBe("sea");
expect(c.theme.tokens["--bg-island"]).toBe("#101820"); // ToolWindow ← *.background wins over the editor
const toml = pluginManifest(c, "Someone", "DeepSea.theme.json");
expect(toml).toContain('id = "theme-sea"');
expect(toml).toContain('editor-scheme = "themes/sea.scheme.json"');
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* The themeable design tokens, read from `src/styles/tokens.css`: every
* variable of the `:root[data-theme="light"]` block (it redefines each colour
* token of the dark `:root`), grouped by the block's section comments.
* Sections named in `SCHEME_GROUPS` belong in a theme's editor colour scheme
* file, the rest in its UI theme file. Pure functions: no Node APIs.
*/
export interface ThemeToken {
name: string;
/** Islands Dark value (`:root`). */
dark: string;
/** Islands Light value. */
light: string;
}
export interface TokenGroup {
/** Section comment of tokens.css, without its parenthetical. */
title: string;
/** True when the group belongs in the editor colour scheme file. */
scheme: boolean;
tokens: ThemeToken[];
}
/** tokens.css sections that IntelliJ keeps in an editor scheme (`.icls`). */
export const SCHEME_GROUPS = ["syntax", "editor", "ANSI palette"];
const LIGHT_SELECTOR = ':root[data-theme="light"]';
/** Raw bodies (comments kept) of top-level rules whose selector list is exactly `selector`. */
function ruleBodies(css: string, selector: string): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
let head = "";
for (let i = 0; i < css.length; i++) {
if (css.startsWith("/*", i)) {
const end = css.indexOf("*/", i + 2);
i = end < 0 ? css.length : end + 1;
continue;
}
const ch = css[i];
if (ch === "{") {
if (depth === 0) {
head = css
.slice(start, i)
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim();
start = i + 1;
}
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
if (head === selector) out.push(css.slice(start, i));
start = i + 1;
}
}
}
return out;
}
const DECL = /^\s*(--[a-z0-9-]+)\s*:\s*(.+?);\s*$/;
function declarations(body: string): Map<string, string> {
const out = new Map<string, string>();
for (const line of body.replace(/\/\*[\s\S]*?\*\//g, "").split("\n")) {
const m = DECL.exec(line);
if (m) out.set(m[1]!, m[2]!);
}
return out;
}
/**
* Themeable tokens in tokens.css order. Throws when a blank-line separated
* chunk of the light block has no section comment, so new tokens always land
* in a named group.
*/
export function themeTokenGroups(css: string): TokenGroup[] {
const [lightBody] = ruleBodies(css, LIGHT_SELECTOR);
if (lightBody === undefined) throw new Error(`tokens.css has no ${LIGHT_SELECTOR} block`);
const dark = new Map<string, string>();
for (const body of ruleBodies(css, ":root")) for (const [k, v] of declarations(body)) dark.set(k, v);
const groups: TokenGroup[] = [];
const placed = new Map<string, TokenGroup>();
let current: TokenGroup | null = null;
let chunkHasComment = false;
const lines = lightBody.replace(/\/\*\s*\n[\s\S]*?\*\//g, "").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
chunkHasComment = false;
current = null;
continue;
}
const comment = /^\/\*\s*(.*?)\s*\*\/$/.exec(trimmed);
if (comment) {
const title = comment[1]!
.replace(/\s*\(.*$/, "")
.replace(/\s+as\s.*$/, "")
.trim();
current = { title, scheme: SCHEME_GROUPS.some((g) => title.startsWith(g)), tokens: [] };
groups.push(current);
chunkHasComment = true;
continue;
}
const m = DECL.exec(line);
if (!m) continue;
if (!current || !chunkHasComment) {
throw new Error(`tokens.css: ${m[1]} in ${LIGHT_SELECTOR} needs a section comment (/* group */) above its chunk`);
}
const name = m[1]!;
// A token listed twice stays in the later group (the redefinition wins in CSS too).
const prev = placed.get(name);
if (prev) prev.tokens = prev.tokens.filter((t) => t.name !== name);
current.tokens.push({ name, light: m[2]!, dark: dark.get(name) ?? m[2]! });
placed.set(name, current);
}
return groups.filter((g) => g.tokens.length > 0);
}
/** Every themeable token name, split into UI theme and editor scheme sets. */
export function tokenSets(groups: readonly TokenGroup[]): { ui: string[]; scheme: string[] } {
const ui: string[] = [];
const scheme: string[] = [];
for (const g of groups) (g.scheme ? scheme : ui).push(...g.tokens.map((t) => t.name));
return { ui, scheme };
}
const cell = (v: string) => "`" + v.replace(/\|/g, "\\|") + "`";
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Markdown token reference (one table per group) for docs/plugins/themes.md. */
export function referenceMarkdown(groups: readonly TokenGroup[]): string {
const { ui, scheme } = tokenSets(groups);
const parts = [
`${ui.length + scheme.length} themeable tokens: ${ui.length} in the UI theme file, ${scheme.length} in the editor colour scheme file.`,
];
for (const g of groups) {
parts.push(
`### ${capitalize(g.title)} (${g.scheme ? "editor scheme" : "UI theme"})`,
[
"| Token | Islands Dark | Islands Light |",
"|---|---|---|",
...g.tokens.map((t) => `| ${cell(t.name)} | ${cell(t.dark)} | ${cell(t.light)} |`),
].join("\n"),
);
}
return parts.join("\n\n");
}
export const REFERENCE_BEGIN = "<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->";
export const REFERENCE_END = "<!-- END GENERATED TOKEN REFERENCE -->";
/** `doc` with the generated reference section replaced. */
export function withReference(doc: string, reference: string): string {
const begin = doc.indexOf(REFERENCE_BEGIN);
const end = doc.indexOf(REFERENCE_END);
if (begin < 0 || end < begin) throw new Error("token reference markers not found");
return `${doc.slice(0, begin + REFERENCE_BEGIN.length)}\n\n${reference}\n\n${doc.slice(end)}`;
}
/** JSON with `//` and `/* *\/` comments removed outside strings (as the plugin host accepts). */
export function stripJsonComments(src: string): string {
let out = "";
let inStr = false;
let escaped = false;
for (let i = 0; i < src.length; i++) {
const c = src[i]!;
if (inStr) {
out += c;
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === '"') inStr = false;
continue;
}
if (c === "/" && src[i + 1] === "/") {
const nl = src.indexOf("\n", i);
if (nl < 0) break;
i = nl - 1;
} else if (c === "/" && src[i + 1] === "*") {
const end = src.indexOf("*/", i + 2);
out += src.slice(i, end < 0 ? src.length : end).replace(/[^\n]/g, "") + " ";
i = end < 0 ? src.length : end + 1;
} else {
inStr = c === '"';
out += c;
}
}
return out;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUnusedLocals": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["./**/*.ts"]
}
@@ -7,10 +7,12 @@ import type { PluginInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
applyTextToActiveEditor,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
pluginActionId,
pluginActions,
@@ -160,20 +162,40 @@ describe("store", () => {
});
describe("bridge events", () => {
it("applies theme tokens as CSS variables and removes them again", () => {
it("applies theme tokens as a stylesheet and removes it again", () => {
const root = document.documentElement;
const sheet = () => document.getElementById(PLUGIN_THEME_STYLE_ID)?.textContent ?? null;
applyPluginTheme({
pluginId: "p",
theme: { id: "t", name: "T", kind: "dark", tokens: { "--bg-app": "#002b36", "--evil": "red; x: y", bad: "1" } },
});
expect(root.style.getPropertyValue("--bg-app")).toBe("#002b36");
expect(root.style.getPropertyValue("--evil")).toBe("");
expect(sheet()).toContain("--bg-app: #002b36;");
expect(sheet()).toContain("color-scheme: dark;");
expect(sheet()).not.toContain("evil");
expect(sheet()).not.toContain("bad");
expect(root.dataset.pluginTheme).toBe("p/t");
applyPluginTheme(null);
expect(root.style.getPropertyValue("--bg-app")).toBe("");
expect(sheet()).toBeNull();
expect(root.dataset.pluginTheme).toBeUndefined();
});
it("switches the built-in base to the theme's kind and restores it", () => {
const root = document.documentElement;
useThemeOverride.setState({ theme: "dark-hc" });
applyThemeAttributes("dark-hc", root);
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["dark", "high"]);
applyPluginTheme({ pluginId: "p", theme: { id: "l", name: "L", kind: "light", tokens: { "--accent": "#123456" } } });
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["light", undefined]);
expect(root.dataset.pluginThemeKind).toBe("light");
// The appearance runtime re-applying the built-in theme keeps the plugin's base.
applyThemeAttributes("dark-hc", root);
expect(root.dataset.theme).toBe("light");
applyPluginTheme(null);
expect([root.dataset.theme, root.dataset.contrast]).toEqual(["dark", "high"]);
expect(root.dataset.pluginThemeKind).toBeUndefined();
useThemeOverride.setState({ theme: null });
});
it("inserts at the cursor and replaces selections", () => {
const v = editor("hello world", 6, 11);
handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" });
+36 -14
View File
@@ -1,13 +1,14 @@
/**
* Glue between backend plugins and the workbench: plugin commands become
* actions, editor requests are applied to the active editor, theme overrides
* become CSS variables, notifications become balloons.
* actions, editor requests are applied to the active editor, the selected
* theme becomes a stylesheet of CSS variables, notifications become balloons.
*/
import { EditorSelection } from "@codemirror/state";
import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { registerAction, type Action } from "@/platform/actions";
import { notifications } from "@/workbench/contributions";
import { applyThemeAttributes, currentThemeId, syncWindowBackground } from "@/workbench/theme";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
@@ -64,23 +65,44 @@ export function syncPluginActions(): () => void {
// --- theme ------------------------------------------------------------------
let appliedTokens: string[] = [];
export const PLUGIN_THEME_STYLE_ID = "openidea-plugin-theme";
/** Applies a plugin theme's token overrides as inline CSS variables on :root (null clears). */
/** Stylesheet for a plugin theme. Its selector outranks every built-in theme block. */
export function pluginThemeCss(theme: ActiveTheme["theme"]): string {
const kind = theme.kind === "light" ? "light" : "dark";
const decls = [` color-scheme: ${kind};`];
for (const [name, value] of Object.entries(theme.tokens)) {
// Backend validates; keep the guard so a malformed payload can't inject declarations.
if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>\\]/.test(value)) continue;
decls.push(` ${name}: ${value};`);
}
return `:root[data-plugin-theme]:not([data-plugin-theme=""]) {\n${decls.join("\n")}\n}\n`;
}
/**
* Applies a plugin theme (null restores the built-in one): its tokens as a
* stylesheet over the built-in light or dark base named by its `kind`. A
* stylesheet rather than inline styles, so the accent picked in Settings ›
* Appearance and the editor colour scheme still layer on top.
*/
export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) {
for (const name of appliedTokens) root.style.removeProperty(name);
appliedTokens = [];
const doc = root.ownerDocument;
doc.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
const had = root.dataset.pluginTheme !== undefined;
if (!theme) {
delete root.dataset.pluginTheme;
return;
delete root.dataset.pluginThemeKind;
if (had) applyThemeAttributes(currentThemeId(), root);
} else {
const style = doc.createElement("style");
style.id = PLUGIN_THEME_STYLE_ID;
style.textContent = pluginThemeCss(theme.theme);
doc.head.appendChild(style);
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
root.dataset.pluginThemeKind = theme.theme.kind === "light" ? "light" : "dark";
applyThemeAttributes(currentThemeId(), root);
}
for (const [name, value] of Object.entries(theme.theme.tokens)) {
// Backend validates; keep the guard so a malformed payload can't inject declarations.
if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>]/.test(value)) continue;
root.style.setProperty(name, value);
appliedTokens.push(name);
}
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
if (had || theme) syncWindowBackground();
}
export function trackPluginTheme(): () => void {
+2
View File
@@ -384,6 +384,7 @@
--fg-gutter: #aeb3c2;
--fg-icon: #6c707e;
/* accent */
--accent: #3871e1;
--link: #2f5eb9;
--link-hover: #2e4d89;
@@ -495,6 +496,7 @@
--ansi-bright-cyan: #1b9ea3;
--ansi-bright-white: #5f6269;
/* shadows and window controls */
--shadow-popup: 0 6px 20px rgba(0, 0, 0, 0.12);
--shadow-dialog: 0 0 0 0.5px rgba(0, 0, 0, 0.1), 0 10px 24px rgba(0, 0, 0, 0.1), 0 24px 72px rgba(0, 0, 0, 0.2);
--traffic-close: #ff5f57;
+18 -4
View File
@@ -72,9 +72,7 @@ export function useApplyTheme() {
useEffect(() => {
const root = document.documentElement;
if (pending && root.dataset.theme) return;
root.dataset.theme = theme;
if (id === "dark-hc") root.dataset.contrast = "high";
else delete root.dataset.contrast;
applyThemeAttributes(id, root);
// Each theme ships its own accent; only a deliberate choice overrides it.
const custom = !!accent && ACCENT_RE.test(accent) && !THEME_ACCENTS.has(accent.toLowerCase());
if (custom) root.style.setProperty("--accent", accent);
@@ -88,12 +86,28 @@ export function useApplyTheme() {
}, [pending, theme, id, accent]);
}
/**
* Sets `data-theme` / `data-contrast` for a built-in theme id. While a plugin
* theme is active (`data-plugin-theme-kind`), its light / dark base wins.
*/
export function applyThemeAttributes(id: "dark" | "light" | "dark-hc", root: HTMLElement = document.documentElement) {
const pluginKind = root.dataset.pluginThemeKind;
if (pluginKind === "light" || pluginKind === "dark") {
root.dataset.theme = pluginKind;
delete root.dataset.contrast;
return;
}
root.dataset.theme = id === "light" ? "light" : "dark";
if (id === "dark-hc") root.dataset.contrast = "high";
else delete root.dataset.contrast;
}
/**
* Where a live resize outruns the page, the native window and the webview (WebView2's default
* background) show their own colours; match both to the frame. The webview one is only set at
* window creation otherwise, so a theme switched later left it light.
*/
function syncWindowBackground() {
export function syncWindowBackground() {
if (!isTauri()) return;
const color = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim();
if (!color) return;
+53
View File
@@ -0,0 +1,53 @@
# Theme plugin template
A complete OpenIDEA theme plugin with one worked example, **Harbor Night**: a
dark theme with slate surfaces, a teal accent and a warm syntax palette. Copy
this folder to start your own theme. Full documentation, the token reference
and porting guides are in [`docs/plugins/themes.md`](../../docs/plugins/themes.md).
```
theme-plugin/
├─ plugin.toml # manifest: id, name, version, the theme files
└─ themes/
├─ harbor-night.theme.json # UI colours, every themeable token, grouped like tokens.css
└─ harbor-night.scheme.json # editor colour scheme: syntax, editor, ANSI colours
```
A theme plugin is data only: no code, no WebAssembly, no build step.
## Make your own
1. Copy the folder, for example to `~/openidea-themes/theme-my-theme`.
2. In `plugin.toml`, change `id` (lowercase, `a-z 0-9 - _`), `name`,
`description`, `authors` and the two paths.
3. Rename the theme files. In the theme file, set `id`, `name` and `kind`
(`"dark"` or `"light"`, the built-in base for anything you leave out).
4. Edit the colours. Each key is a CSS variable from `src/styles/tokens.css`,
and the reference in the docs gives the Islands Dark and Light values.
Comments (`//`, `/* */`) are allowed. Delete a line to keep the built-in value.
Porting an IntelliJ theme? `pnpm theme:import-intellij <file.theme.json> [file.icls] --out <dir>`
in the OpenIDEA repository generates a package in this layout.
## Package and install
The folder is the package. To share it, publish the folder as a Git repository
or a zip that users extract.
1. In OpenIDEA open **Settings › Plugins**, click the gear menu and choose
**Install Plugin from Disk…** (or run the *Install Plugin from Disk…*
action), then pick the folder that contains `plugin.toml`.
2. Select the plugin in the *Installed* tab and press **Apply** next to the theme.
3. **Use Default Theme** in the same place goes back to the built-in theme.
## Iterate
Put (or symlink) the folder in `<project>/.openidea/plugins/` of an open
project. It loads as a *dev* plugin, and **Reload** in Settings › Plugins (or
the *Reload Plugins* action) re-reads your edits without reinstalling. Invalid
JSON or values are reported in Settings › Plugins with the file and line.
## Licence
The template and Harbor Night are part of OpenIDEA (Apache-2.0). When you port
someone else's theme, keep its licence and credit its authors.
+15
View File
@@ -0,0 +1,15 @@
# A theme plugin is data only: no `wasm`, no permissions, nothing to compile.
# Rename the id, name and files when you start your own theme from this template.
id = "theme-harbor-night"
name = "Harbor Night Theme"
version = "1.0.0"
description = "Example dark theme: slate surfaces, a teal accent and a warm syntax palette."
authors = ["OpenIDEA contributors"]
change-notes = "1.0.0 — first release."
openidea-api = "0.1"
# One entry per theme. `editor-scheme` is optional: its tokens are merged over
# the theme's (IntelliJ's .theme.json + .icls pair).
[[contributes.themes]]
path = "themes/harbor-night.theme.json"
editor-scheme = "themes/harbor-night.scheme.json"
@@ -0,0 +1,94 @@
// Harbor Night: editor colour scheme (syntax, editor surfaces, console ANSI colours),
// the counterpart of an IntelliJ .icls file. Merged over the UI theme's tokens.
{
"name": "Harbor Night",
"tokens": {
// Syntax
"--syn-keyword": "#e0a46e",
"--syn-string": "#9ccc7a",
"--syn-type": "#8fd4c9",
"--syn-function": "#f2d07a",
"--syn-function-call": "#d9dfe8",
"--syn-comment": "#6b7a8f",
"--syn-comment-style": "normal",
"--syn-doc-comment": "#74889c",
"--syn-docstring": "#8fb071",
"--syn-annotation": "#d7a0e0",
"--syn-decorator": "#d7a0e0",
"--syn-number": "#7fc8f8",
"--syn-punct": "#a9b4c2",
"--syn-variable": "#d9dfe8",
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
"--syn-predefined": "#c4a0f2",
"--syn-escape": "#7fd1c4",
"--syn-regexp": "#7fd1c4",
"--syn-tag": "#e0a46e",
"--syn-attribute": "#9fc0e8",
"--syn-todo": "#7fd1c4",
"--syn-link": "#6fc3df",
"--syn-markup-heading": "#f2d07a",
"--syn-markup-code": "#9ccc7a",
"--syn-invalid": "#ff6b7a",
"--syn-inline-value": "#6b7a8f",
// Editor
"--fg-editor": "#d9dfe8",
"--caret": "#5ce0d2",
"--fg-line-number": "#3f4d60",
"--fg-line-number-current": "#93a1b4",
"--border-gutter": "#1f252e",
"--border-indent-guide": "#2a313b",
"--border-indent-guide-active": "#424e5e",
"--border-right-margin": "#2a313b",
"--bg-editor-selection": "#2d7772",
"--bg-editor-selection-inactive": "#255854",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-matched-brace": "#38424f",
"--matched-brace-weight": "700",
"--bg-matched-tag": "#353e4a",
"--bg-fold-placeholder": "#303944",
"--fg-fold-placeholder": "#75869d",
"--fg-fold-marker": "#5f6f85",
"--fg-whitespace": "#5f6f85",
"--fg-unused": "#5f6f85",
"--bg-identifier-under-caret": "#2e353f",
"--bg-identifier-under-caret-write": "#402f33",
"--fg-inlay": "#75869d",
"--bg-inlay": "#303944",
"--wave-error": "#fa6675",
"--wave-warning": "#f2c55c",
"--editor-error-decoration": "underline wavy #fa6675",
"--editor-warning-decoration": "underline wavy #f2c55c",
"--editor-warning-background": "transparent",
"--bg-breakpoint-line": "#40252b",
"--fg-completion-match": "#68e4dc",
"--bg-editor-inline": "#1b2027",
"--bg-editor-banner": "#2a514f",
"--fg-weak-warning": "#5f6f85",
"--fg-bulb": "#f2c55c",
// ANSI palette
"--ansi-black": "#000000",
"--ansi-red": "#f0524f",
"--ansi-green": "#5c962c",
"--ansi-yellow": "#a68a0d",
"--ansi-blue": "#4cc1b9",
"--ansi-magenta": "#a771bf",
"--ansi-cyan": "#00a3a3",
"--ansi-white": "#6e7b8d",
"--ansi-bright-black": "#4c5561",
"--ansi-bright-red": "#ff4050",
"--ansi-bright-green": "#4fc414",
"--ansi-bright-yellow": "#e5bf00",
"--ansi-bright-blue": "#3be3d8",
"--ansi-bright-magenta": "#ed7eed",
"--ansi-bright-cyan": "#00e5e5",
"--ansi-bright-white": "#ffffff"
}
}
@@ -0,0 +1,140 @@
// Harbor Night: UI theme. Keys are the CSS variables of src/styles/tokens.css,
// grouped like its Islands Light block. Delete a line to keep the built-in value
// of the base named by "kind". Comments are allowed (// and /* */).
// Full reference: docs/plugins/themes.md.
{
"id": "harbor-night",
"name": "Harbor Night",
"kind": "dark",
"tokens": {
// Surfaces
"--bg-app": "#14171c",
"--bg-elev": "#1b2027",
"--bg-popup": "#1f252e",
"--bg-hover": "#262c33",
"--bg-selected": "#22484b",
"--bg-selected-strong": "#285a5d",
"--bg-current-line": "#191e25",
"--bg-debug-line": "#245a5c",
"--bg-backdrop": "#1f252e",
"--bg-input": "#14171c",
// Lines
"--border": "#1f252e",
"--border-popup": "#2a333f",
"--border-strong": "#36414f",
"--border-window": "#36414f",
"--control-off": "#515f72",
// Text
"--fg-strong": "#d9dfe6",
"--fg-emphasis": "#c9d1dc",
"--fg": "#c9d1dc",
"--fg-secondary": "#919fb1",
"--fg-muted": "#7c8ca0",
"--fg-subtle": "#637287",
"--fg-faint": "#515f72",
"--fg-gutter": "#3f4d60",
"--fg-icon": "#c6ced9",
// Accent
"--accent": "#2b9e93",
"--link": "#5cc8bd",
"--link-hover": "#8adcd3",
// Status
"--danger": "#f57e84",
"--warning": "#d59637",
"--success": "#6db083",
"--db-connected": "#2fe02f",
"--db-connected-ring": "#1d6b1d",
"--info": "#4fb0d0",
// Vcs
"--vcs-modified": "#82ede6",
"--vcs-added": "#73bd79",
"--vcs-deleted": "#5f6f85",
"--vcs-untracked": "#e88f89",
"--vcs-ignored": "#d69a6b",
"--vcs-conflict": "#de6a66",
"--gutter-modified": "#469e98",
"--gutter-added": "#549159",
"--gutter-deleted": "#75869d",
// Shadows and window controls
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
"--shadow-dialog": "0 0 0 0.5px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.35), 0 24px 72px rgba(0, 0, 0, 0.45)",
"--traffic-close": "#ff5f57",
"--traffic-close-border": "#e0443e",
"--traffic-close-pressed": "#bf4942",
"--traffic-glyph": "#4d0000",
"--traffic-off": "#404955",
"--traffic-off-border": "#363e49",
// Additive
"--bg-tooltip": "#2a333f",
"--fg-tooltip": "#c9d1dc",
"--border-tooltip": "#2a333f",
"--bg-control": "#1f252e",
"--bg-control-hover": "#2a333f",
"--bg-active": "#36414f",
"--bg-scrim": "rgba(0, 0, 0, 0.45)",
"--fg-on-accent": "#ffffff",
"--shadow-tooltip": "0 4px 12px rgba(0, 0, 0, 0.4)",
"--shadow-balloon": "0 8px 24px rgba(0, 0, 0, 0.45)",
// Islands contract
"--bg-frame": "#1f252e",
"--bg-island": "#14171c",
"--bg-stripe-hover": "#ffffff17",
"--bg-stripe-selected": "#ffffff29",
"--fg-stripe-selected-active": "#ffffff",
"--fg-stripe-label": "#919fb1",
"--bg-tab-selected": "#1e3d40",
"--border-tab-selected": "#2b9e93",
"--bg-tab-selected-inactive": "#1f252e",
"--border-tab-selected-inactive": "#36414f",
"--bg-tab-hover": "#ffffff17",
"--border-editor-tabs": "#1f252e",
"--bg-tree-selected": "#22484b",
"--bg-tree-selected-inactive": "#2a333f",
"--bg-tree-hover": "#ffffff10",
"--bg-toolbar-hover": "#ffffff17",
"--bg-toolbar-pressed": "#ffffff29",
"--bg-toolbar-selected": "#285a5d",
"--bg-toolbar-selected-hover": "#2f6b6e",
"--bg-button-primary-hover": "#40a8a1",
"--fg-button-primary": "#ffffff",
"--bg-button": "transparent",
"--border-button": "#36414f",
"--bg-field": "#14171c",
"--bg-checkbox": "#14171c",
"--border-checkbox": "#515f72",
"--bg-toggle-on": "#338555",
"--fg-toggle-on": "#ffffff",
"--bg-toggle-off": "#14171c",
"--fg-toggle-off": "#637287",
"--border-toggle": "#515f72",
"--bg-toggle-knob": "#515f72",
"--focus-ring-soft": "#467c78",
"--scrollbar-thumb": "#ffffff33",
"--scrollbar-thumb-hover": "#ffffff59",
"--bg-search-match": "#ba9752",
"--bg-search-match-current": "#f2c55c",
"--fg-search-match": "#14171c",
"--bg-dialog": "#14171c",
"--bg-popup-inline": "#1f252e",
"--bg-popup-advertiser": "#1f252e",
"--bg-feedback-info": "#2a514f",
"--border-feedback-info": "#397e79",
"--bg-feedback-error": "#56272b",
"--border-feedback-error": "#80383e",
"--bg-feedback-warning": "#44321d",
"--border-feedback-warning": "#694820",
"--bg-feedback-success": "#203b2a",
"--border-feedback-success": "#29583c",
"--bg-counter": "#637287",
"--fg-counter": "#000000",
"--bg-progress-track": "#36414f"
}
}
+1 -1
View File
@@ -28,6 +28,6 @@ export default defineConfig(() => ({
environment: "jsdom",
globals: false,
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}", "perf/**/*.test.ts"],
include: ["src/**/*.test.{ts,tsx}", "perf/**/*.test.ts", "scripts/**/*.test.ts"],
},
}));