mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user