diff --git a/README.en.md b/README.en.md index b6fbde4..90c73d8 100644 --- a/README.en.md +++ b/README.en.md @@ -27,8 +27,12 @@ Agent Theme is a standalone desktop app (Tauri v2). The agent (Codex Desktop / A |---|---|---| | **Codex Desktop** | ✅ Supported | overrides Tailwind v4 `--color-token-*` design tokens + per-module frosted glass | | **Antigravity** | ✅ Supported | overrides shadcn / `--vscode-*` semantic tokens + per-panel frosted glass + chat-prose / code-block recolouring | +| **Linear** | ✅ Supported | rides Linear's **Dark mode**: wallpaper layer + content surfaces made translucent + frosted glass on sidebar / tabs / cards / menus / dialogs | -> Both are injected at the CDP runtime and share `theme.json`'s colour knobs; support for more agents will be added over time. +> All three are injected at the CDP runtime and share `theme.json`'s colour knobs; support for more agents will be added over time. + +> [!IMPORTANT] +> **Linear must be set to Dark mode first** (Settings → Preferences → Interface theme → **Dark**). Linear's colours are driven by three systems (StyleX atomic vars + legacy `--color-*` + hardcoded literals), so light mode can't be cleanly retheme'd (text stays invisible); in Dark mode Linear natively renders dark surfaces + light text, and the theme only layers a wallpaper + frosted glass on top — crisp and flicker-free. ## Theme Showcase @@ -38,6 +42,10 @@ Every theme is **colour-matched individually** to its own background — glass t |---|---| | ![Changli](docs/antigravity/changli.jpg) | ![Frost](docs/antigravity/frost.jpg) | +And the actual look on Linear (Dark mode + Changli — sidebar / title / content text blurred for privacy): + +![Linear · Changli](docs/linear/changli.jpg) + **11** built-in themes (backgrounds are the respective character artworks — see [Disclaimer](#disclaimer)): | ID | English | ID | English | diff --git a/README.md b/README.md index 2231fcb..23e8ec6 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,12 @@ Agent Theme 是一个独立的桌面应用(Tauri v2)。代理(Codex Desktop / An |---|---|---| | **Codex Desktop** | ✅ 已适配 | 覆盖 Tailwind v4 `--color-token-*` 设计令牌 + 各模块磨砂玻璃 | | **Antigravity** | ✅ 已适配 | 覆盖 shadcn / `--vscode-*` 语义令牌 + 各面板磨砂玻璃 + 对话正文 / 代码块重配色 | +| **Linear** | ✅ 已适配 | 基于 Linear **暗色模式**叠壁纸 + 内容区透明化 + 侧栏/标签/卡片/菜单/弹窗磨砂玻璃 | -> 两者都通过 CDP 运行时注入、共用 `theme.json` 的配色旋钮;后续适配更多代理会陆续加入。 +> 三者都通过 CDP 运行时注入、共用 `theme.json` 的配色旋钮;后续适配更多代理会陆续加入。 + +> [!IMPORTANT] +> **Linear 需先设为暗色模式**(Settings → Preferences → Interface theme → **Dark**)。Linear 的颜色由 StyleX 原子变量 + 旧 `--color-*` + 硬编码字面色三套系统驱动,浅色模式无法干净覆盖文字;暗色模式下 Linear 原生渲染暗底浅字,换肤只在其上叠壁纸与磨砂玻璃,清晰不闪。 ## 主题展示 @@ -38,6 +42,10 @@ Agent Theme 是一个独立的桌面应用(Tauri v2)。代理(Codex Desktop / An |---|---| | ![Changli](docs/antigravity/changli.jpg) | ![Frost](docs/antigravity/frost.jpg) | +Linear 上的实际效果(暗色模式 + 长离 Changli,侧栏 / 标题 / 内容文字已做模糊处理): + +![Linear · Changli](docs/linear/changli.jpg) + 内置 **11 套**主题(背景图为各自角色美术,详见[免责声明](#免责声明)): | ID | 中文名 | English | ID | 中文名 | English | diff --git a/src-tauri/src/cdp.rs b/src-tauri/src/cdp.rs index c7a673c..0e36118 100644 --- a/src-tauri/src/cdp.rs +++ b/src-tauri/src/cdp.rs @@ -66,6 +66,20 @@ pub fn find_main_target<'a>(targets: &'a [Target], kind: &AgentKind) -> Option<& .iter() .find(|t| t.target_type == "page" && t.title.contains("Antigravity")) } + AgentKind::Linear => { + // Thin Electron shell that loads the remote web app directly + // (renderer/index.html is empty; main process loads + // https://linear.app/auth/desktop → https://linear.app). + if let Some(main) = targets + .iter() + .find(|t| t.url.starts_with("https://linear.app") && t.target_type == "page") + { + return Some(main); + } + targets + .iter() + .find(|t| t.target_type == "page" && t.title.contains("Linear")) + } } } @@ -200,6 +214,27 @@ pub async fn clear_theme( Ok(()) } +pub async fn reload_page(port: u16, kind: &AgentKind) -> Result<(), String> { + let targets = list_targets(port).await?; + let target = + find_main_target(&targets, kind).ok_or("Could not find Agent main window target")?; + + let ws_url = target + .web_socket_debugger_url + .as_ref() + .ok_or("Target has no WebSocket URL")?; + let (mut ws_stream, _): (WebSocketStream>, _) = + connect_async(ws_url.as_str()) + .await + .map_err(|e| format!("WebSocket connect failed: {}", e))?; + + make_cdp_request(&mut ws_stream, "Page.enable", serde_json::json!({})).await?; + make_cdp_request(&mut ws_stream, "Page.reload", serde_json::json!({})).await?; + + let _ = ws_stream.close(None).await; + Ok(()) +} + #[cfg(test)] mod tests { use super::{find_main_target, Target}; @@ -235,25 +270,19 @@ mod tests { assert_eq!(target.map(|t| t.url.as_str()), Some("app://-/index.html")); } -} -pub async fn reload_page(port: u16, kind: &AgentKind) -> Result<(), String> { - let targets = list_targets(port).await?; - let target = - find_main_target(&targets, kind).ok_or("Could not find Agent main window target")?; - - let ws_url = target - .web_socket_debugger_url - .as_ref() - .ok_or("Target has no WebSocket URL")?; - let (mut ws_stream, _): (WebSocketStream>, _) = - connect_async(ws_url.as_str()) - .await - .map_err(|e| format!("WebSocket connect failed: {}", e))?; + #[test] + fn finds_linear_remote_page() { + let targets = vec![page( + "MOC-130 issue", + "https://linear.app/mochance/issue/MOC-130", + )]; - make_cdp_request(&mut ws_stream, "Page.enable", serde_json::json!({})).await?; - make_cdp_request(&mut ws_stream, "Page.reload", serde_json::json!({})).await?; + let target = find_main_target(&targets, &AgentKind::Linear); - let _ = ws_stream.close(None).await; - Ok(()) + assert_eq!( + target.map(|t| t.url.as_str()), + Some("https://linear.app/mochance/issue/MOC-130") + ); + } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 26cb62f..9584ba3 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -11,6 +11,7 @@ pub enum AgentKind { #[default] Codex, Antigravity, + Linear, } impl fmt::Display for AgentKind { @@ -18,6 +19,7 @@ impl fmt::Display for AgentKind { match self { AgentKind::Codex => write!(f, "Codex"), AgentKind::Antigravity => write!(f, "Antigravity"), + AgentKind::Linear => write!(f, "Linear"), } } } @@ -27,6 +29,7 @@ impl AgentKind { match self { AgentKind::Codex => "Codex", AgentKind::Antigravity => "Antigravity", + AgentKind::Linear => "Linear", } } @@ -34,6 +37,7 @@ impl AgentKind { match self { AgentKind::Codex => "Codex", AgentKind::Antigravity => "Antigravity", + AgentKind::Linear => "Linear", } } @@ -42,6 +46,7 @@ impl AgentKind { match self { AgentKind::Codex => "Codex", AgentKind::Antigravity => "Antigravity", + AgentKind::Linear => "Linear", } } @@ -49,6 +54,7 @@ impl AgentKind { match self { AgentKind::Codex => "/Applications/Codex.app", AgentKind::Antigravity => "/Applications/Antigravity.app", + AgentKind::Linear => "/Applications/Linear.app", } } @@ -56,6 +62,7 @@ impl AgentKind { match self { AgentKind::Codex => "/Applications/Codex.app/Contents/MacOS/Codex", AgentKind::Antigravity => "/Applications/Antigravity.app/Contents/MacOS/Antigravity", + AgentKind::Linear => "/Applications/Linear.app/Contents/MacOS/Linear", } } @@ -64,6 +71,7 @@ impl AgentKind { match self { AgentKind::Codex => vec!["Codex"], AgentKind::Antigravity => vec!["Antigravity"], + AgentKind::Linear => vec!["Linear"], } } @@ -72,6 +80,7 @@ impl AgentKind { match self { AgentKind::Codex => vec!["/Applications/Codex.app/"], AgentKind::Antigravity => vec!["/Applications/Antigravity.app/"], + AgentKind::Linear => vec!["/Applications/Linear.app/"], } } } diff --git a/src-tauri/src/theme.rs b/src-tauri/src/theme.rs index 6f922ae..b9b08cf 100644 --- a/src-tauri/src/theme.rs +++ b/src-tauri/src/theme.rs @@ -190,6 +190,7 @@ pub fn generate_injection_script(theme: &Theme, kind: &AgentKind) -> Result generate_codex_injection_script(theme), AgentKind::Antigravity => generate_antigravity_injection_script(theme), + AgentKind::Linear => generate_linear_injection_script(theme), } } @@ -855,6 +856,244 @@ fn generate_antigravity_injection_script(theme: &Theme) -> Result + workspace sidebar; RIGHT = the issue-detail properties panel (.sc-jCgzYM — build-specific, + re-capture via enum-views.mjs). */ +nav{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +border-right:1px solid var(--cl-border) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 3px)) saturate(118%);backdrop-filter:blur(calc(var(--cl-blur) + 3px)) saturate(118%); +} +.sc-jCgzYM,.sc-fcSRSc{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +border-left:1px solid var(--cl-border) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 3px)) saturate(118%);backdrop-filter:blur(calc(var(--cl-blur) + 3px)) saturate(118%); +} +/* the content text (native light) sits directly on the wallpaper+veil, not on a panel — + a shadow keeps it crisp over the busier lower wallpaper. Two-stop (tight dark core + + soft halo) so the DIM secondary text (issue ids / timestamps / counts, which dark mode + renders as low-contrast grey) stays legible over a bright wallpaper patch, without + touching the native text colour. */ +main,main *{text-shadow:0 1px 2px rgba(0,0,0,.7),0 0 4px rgba(0,0,0,.45);} +main h1,main h2,main [class*="title" i]{text-shadow:0 1px 3px rgba(0,0,0,.68),0 0 2px rgba(0,0,0,.55);} +/* input / textarea PLACEHOLDER text is a faint low-opacity grey that vanishes on the wallpaper + (e.g. "Description (optional)" on the New-view form). Lift it to a higher-contrast warm ink at + full opacity + a shadow so the prompt reads, while still sitting below real entered text. */ +::placeholder,::-webkit-input-placeholder,textarea::placeholder,input::placeholder{ +color:color-mix(in srgb,var(--cl-ink) 72%,transparent) !important;opacity:1 !important;text-shadow:0 1px 2px rgba(0,0,0,.55);} +/* ── ELEVATED chrome → frosted glass mask (translucent dark + blur). The page background + shows the wallpaper, but discrete grouped/floating surfaces must stay readable: the top + tab strip, settings/section CARDS, modals, menus / popovers / dropdowns / tooltips, and + floating pills (Ask Linear / command bar). Native dark mode renders these opaque-dark; we + re-skin them to translucent frosted glass so text + borders read AND the wallpaper frosts + through. componentIds are build-specific (re-capture via enum-views.mjs); role / radix + hooks are stable. ── */ +.sc-gAeEkc,.sc-iVkNzS, +[role="dialog"],[aria-modal="true"],[role="menu"],[role="listbox"],[role="tooltip"], +[data-radix-popper-content-wrapper]>*,[cmdk-root],[cmdk-dialog]{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%);backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%); +border:1px solid var(--cl-border-soft) !important; +} +/* create-issue / command modals: the [role="dialog"] (sc-jOEaPZ) is a FULL-SCREEN wrapper, not + the card — so the elevated-glass rule above frosted the ENTIRE screen into a dark full-screen + mask. Override that wrapper to fully transparent (no mask), and instead frost the actual centered + CARD (sc-ceMZYT). Later rule + equal specificity → wins over [role=dialog]. Build-specific ids. */ +.sc-jOEaPZ{background:transparent !important;-webkit-backdrop-filter:none !important;backdrop-filter:none !important;border:none !important;} +.sc-ceMZYT{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%);backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%); +border:1px solid var(--cl-border-soft) !important; +} +/* the top tab strip (sc-fqmtlO) → FULLY transparent so the wallpaper flows all the way to the + top edge (user wants no dark top bar). Tab labels are native light + carry text-shadow. */ +.sc-fqmtlO{background:transparent !important;border:none !important;-webkit-backdrop-filter:none !important;backdrop-filter:none !important;} +/* hover-detail CARDS (status "Time in status" / sub-issues / labels / project & issue previews) + are one shared component whose surface is `.sx-1lmytr0` — the same base-surface StyleX class + we transparentise for in-flow content wrappers, so transparentising it stripped these cards' + background and left the text floating unreadably. They live inside a position:fixed floater + (`.sx-ixxii4`), so scope the glass to `.sx-ixxii4 .sx-1lmytr0`: re-skins ONLY the floating + cards (higher specificity than the transparentise rule → wins), while in-flow content wrappers + stay transparent (body carries no `.sx-ixxii4`). Cards mount fresh on hover → no repaint lag. */ +.sx-ixxii4 .sx-1lmytr0{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%);backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%); +} +/* board/list issue CARDS were opaque black plates (sc-fjSLwO = an lch surface). Re-skin each + to the same translucent frosted glass as the rest of the chrome so a card reads as a glass + tile over the wallpaper rather than a solid block. Board cards are virtualised (few live at + once) so per-card blur is fine. Build-specific componentId — re-capture via cardpanel probe. + sc-bEnKrG is the actual opaque bar behind list GROUP HEADERS (In Review / In Progress / Todo); + sc-flFoxq is a related highlighted-row surface — same glass so neither stays an opaque black + bar. (These carry the visible bg; text-search hits inner wrappers, so they were found by + pixel-position elementFromPoint — re-capture that way on a Linear build change.) */ +.sc-fjSLwO,.sc-flFoxq,.sc-bEnKrG{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 2px)) saturate(118%);backdrop-filter:blur(calc(var(--cl-blur) + 2px)) saturate(118%); +border:1px solid var(--cl-border-soft) !important; +} +/* modal/dialog dim BACKDROP (StyleX atomic `.sx-96pfka` = background var(--sx-1qdowq0), a + ~25% black scrim) — clear it so a frosted modal shows the WALLPAPER behind (like the + sidebar) instead of a flat dark plate. The content under it is already transparent, so + the modal's blur frosts the wallpaper+veil, not the page chrome. Build-specific atomic + class — re-capture via enum-rules.mjs / bdclass probe on a Linear update. */ +.sx-96pfka{background-color:transparent !important;} +/* floating bottom-right pills (Ask Linear / AI assistant ⌘J) — small transparent buttons + over the wallpaper; give them a compact glass pill so the label reads. */ +.sc-fCUGvV{ +background:linear-gradient(var(--cl-scrim-bot),var(--cl-scrim-bot)),var(--cl-glass) !important;border:1px solid var(--cl-border-soft) !important;border-radius:8px !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%);backdrop-filter:blur(calc(var(--cl-blur) + 4px)) saturate(120%); +} +__ACCENT_BLOCK__"#; + +/// Accent-cohesion block for Linear, emitted only when the theme declares +/// `style.accent`. Remaps Linear's brand indigo (#6d78d5 focus ring / +/// lch(53% 52.26 286.91) focus) plus link / selection / selected-row / active-tab / +/// checkbox accents to the theme accent; on-accent text uses the theme's dark +/// `base_color` for contrast. Omitted entirely for accent-less themes. +const LINEAR_ACCENT_BLOCK: &str = r#":root{--cl-accent:__ACCENT__;--cl-accent-soft:__ACCENT_SOFT__;--cl-focus:__FOCUS__;} +html{ +--focus-ring-color:var(--cl-focus) !important; +--focus-color:var(--cl-focus) !important; +--focus-ring-outline:1px solid var(--cl-focus) !important; +} +a,a:visited,[class*="link" i]{color:var(--cl-accent) !important;} +::selection{background:color-mix(in srgb,var(--cl-accent) 32%,transparent);} +/* row HOVER + selected/active states were opaque dark bars; re-skin to a warm accent-tinted + FROSTED GLASS (translucent + blur) so hovering/selecting a row reads as a frosted highlight + over the wallpaper, on-theme, never a solid plate. */ +[data-list-row="true"]:hover,[data-list-row="true"][data-selected="true"],[data-list-row="true"][data-active="true"],[data-list-row="true"][data-keyboard-active="true"]{ +background:linear-gradient(color-mix(in srgb,var(--cl-accent) 15%,transparent),color-mix(in srgb,var(--cl-accent) 15%,transparent)),var(--cl-glass-soft) !important; +-webkit-backdrop-filter:blur(calc(var(--cl-blur) + 2px)) saturate(118%);backdrop-filter:blur(calc(var(--cl-blur) + 2px)) saturate(118%); +box-shadow:inset 2px 0 0 var(--cl-accent) !important; +} +/* active view tab (Assigned/Created/...) + selected sidebar nav item: accent text. */ +[data-desktop-tab="true"][data-selected="true"],[aria-current="page"],[aria-selected="true"]{ +color:var(--cl-accent) !important; +} +[data-desktop-tab="true"][data-selected="true"]{box-shadow:inset 0 -2px 0 var(--cl-accent) !important;} +/* checked checkboxes / radios / toggles → accent fill. */ +[role="checkbox"][aria-checked="true"],[data-state="checked"],input[type="checkbox"]:checked{ +background-color:var(--cl-accent) !important;border-color:var(--cl-accent) !important;color:__BASECOLOR__ !important; +} +/* primary action buttons (brand indigo by default) → accent. */ +button[type="submit"]:not([disabled]){background-color:var(--cl-accent) !important;color:__BASECOLOR__ !important;}"#; + +fn generate_linear_injection_script(theme: &Theme) -> Result { + let bg = encode_background(theme)?; + let st = resolve_style(&theme.style); + let pos = theme + .background_position + .clone() + .unwrap_or_else(|| "center top".to_string()); + let fit = theme + .background_fit + .clone() + .unwrap_or_else(|| "cover".to_string()); + + let accent_block = match &st.accent { + Some(a) => LINEAR_ACCENT_BLOCK + .replace("__ACCENT_SOFT__", &st.accent_soft) + .replace("__FOCUS__", &st.focus) + .replace("__BASECOLOR__", &st.base_color) + .replace("__ACCENT__", a), + None => String::new(), + }; + + let css = LINEAR_CSS_TEMPLATE + .replace("__HERO__", &bg) + .replace("__BASECOLOR__", &st.base_color) + .replace("__POS__", &pos) + .replace("__FIT__", &fit) + .replace("__INK2__", &st.ink2) + .replace("__INK3__", &st.ink3) + .replace("__INK4__", &st.ink4) + .replace("__INK__", &st.ink) + .replace("__SURFACE__", &st.surface) + .replace("__GLASS_STRONG__", &st.glass_strong) + .replace("__GLASS_SOFT__", &st.glass_soft) + .replace("__GLASS__", &st.glass) + .replace("__BORDER_STRONG__", &st.border_strong) + .replace("__BORDER_SOFT__", &st.border_soft) + .replace("__BORDER__", &st.border) + .replace("__BLUR__", &st.blur) + .replace("__HOVER__", &st.hover) + .replace("__SELECTION__", &st.selection) + .replace("__SCRIM_TOP__", &st.scrim_top) + .replace("__SCRIM_MID__", &st.scrim_mid) + .replace("__SCRIM_BOT__", &st.scrim_bot) + .replace("__ACCENT_BLOCK__", &accent_block); + + Ok(wrap_injection_css(&css, "Linear")) +} + #[cfg(test)] mod tests { use super::*; @@ -871,9 +1110,16 @@ mod tests { #[test] fn every_bundled_theme_parses_and_generates() { // all themes//theme.json must deserialize into Theme and produce a - // non-empty Codex injection script (catches agent-written JSON typos / - // field-type mismatches that get_themes() would otherwise silently skip). + // non-empty injection script for EVERY agent (catches agent-written JSON typos + // / field-type mismatches that get_themes() would otherwise silently skip, and + // guards that no theme breaks one agent's template). One stable per-agent marker + // confirms the right template ran. let themes_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../themes"); + let agents = [ + (AgentKind::Codex, ".app-shell-left-panel"), + (AgentKind::Antigravity, ".bg-card"), + (AgentKind::Linear, "#mainLayoutContainer"), + ]; let mut count = 0; for entry in std::fs::read_dir(&themes_dir) .expect("read themes dir") @@ -888,13 +1134,15 @@ mod tests { let mut t: Theme = serde_json::from_str(&raw) .unwrap_or_else(|e| panic!("theme.json failed to parse at {:?}: {}", json, e)); t.dir = dir.clone(); - let script = generate_injection_script(&t, &AgentKind::Codex) - .unwrap_or_else(|e| panic!("inject script failed for {:?}: {}", dir, e)); - assert!( - script.contains("data:image/") && script.contains(".app-shell-left-panel"), - "theme {:?} produced an incomplete script", - t.id - ); + for (kind, marker) in &agents { + let script = generate_injection_script(&t, kind) + .unwrap_or_else(|e| panic!("inject script failed for {:?} {kind}: {}", dir, e)); + assert!( + script.contains("data:image/") && script.contains(marker), + "theme {:?} produced an incomplete {kind} script", + t.id + ); + } count += 1; } assert!(count >= 5, "expected several bundled themes, found {count}"); @@ -989,4 +1237,53 @@ mod tests { // default background position assert!(script.contains("center top")); } + + #[test] + fn linear_script_applies_modular_style() { + let theme = load_changli(); + let script = generate_injection_script(&theme, &AgentKind::Linear).unwrap(); + // background image inlined as a data URI + assert!(script.contains("data:image/jpeg;base64,")); + // the same --cl-* knobs as Codex/Antigravity feed the dark-mode overlay + assert!(script.contains("--cl-ink:#f4ebdf")); + // dark-mode overlay: force dark scheme + transparentise opaque content surfaces + assert!(script.contains("color-scheme:dark")); + assert!(script.contains("--color-bg-primary:transparent")); + // stable surface hooks (IDs / semantic class / data-attr) + a build-specific sc- id + assert!(script.contains("#mainLayoutContainer")); + assert!(script.contains(".section-to-print")); + assert!(script.contains(r#"[data-list-row="true"]"#)); + // wallpaper layer + content-region readability veil + assert!(script.contains("html::before")); + assert!(script.contains("html::after")); + // elevated chrome (modals / menus) re-skinned to frosted glass + assert!(script.contains(r#"[role="dialog"]"#)); + // warm accent from style.accent threads through focus ring + links + assert!(script.contains("#e08a55")); + assert!(script.contains("--focus-ring-color:var(--cl-focus)")); + assert!(script.contains(r#"a,a:visited,[class*="link" i]{color:var(--cl-accent)"#)); + // background position from theme.json + assert!(script.contains("50% 4%")); + // ISOLATION: must NOT carry Codex-only or Antigravity-only selectors/tokens + assert!(!script.contains(".app-shell-left-panel")); + assert!(!script.contains("--color-token-main-surface-primary")); + assert!(!script.contains(".bg-card")); + if let Ok(path) = std::env::var("DUMP_LINEAR_SCRIPT") { + std::fs::write(path, &script).unwrap(); + } + } + + #[test] + fn linear_script_falls_back_to_neutral_defaults_without_style() { + let mut theme = load_changli(); + theme.style = None; + theme.background_position = None; + let script = generate_injection_script(&theme, &AgentKind::Linear).unwrap(); + // neutral default ink colour is used + assert!(script.contains("--cl-ink:#f1ece4")); + // no accent declared -> accent block omitted (focus-ring remap absent) + assert!(!script.contains("--focus-ring-color:var(--cl-focus)")); + // default background position + assert!(script.contains("center top")); + } } diff --git a/src/lib/actions.ts b/src/lib/actions.ts index eba7e81..74c726e 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -6,6 +6,7 @@ import { isRestartingStore, } from './stores'; import * as cmd from './tauri-commands'; +import type { AgentKind } from './types'; function messageFromError(err: unknown) { if (typeof err === 'string') return err; @@ -89,7 +90,7 @@ export async function setEnabled(enabled: boolean) { } } -export async function switchAgent(agent: 'codex' | 'antigravity') { +export async function switchAgent(agent: AgentKind) { lastErrorStore.set(null); try { const config = await cmd.setSelectedAgent(agent); diff --git a/src/lib/components/AgentSelector.svelte b/src/lib/components/AgentSelector.svelte index 6b9be43..092e499 100644 --- a/src/lib/components/AgentSelector.svelte +++ b/src/lib/components/AgentSelector.svelte @@ -1,10 +1,16 @@ @@ -23,7 +29,7 @@ : 'text-[#1e293b] bg-[linear-gradient(180deg,rgba(255,255,255,0.9)_0%,rgba(230,238,250,0.85)_100%)] border-t-[rgba(255,255,255,0.95)] border-b-[rgba(180,200,230,0.35)] shadow-[0_1px_3px_rgba(30,41,59,0.12),0_-1px_0_rgba(255,255,255,0.8)_inset]'}" on:click={() => handleClick(agent)} > - {agent === 'codex' ? 'Codex' : 'Antigravity'} + {labels[agent]} {/each} diff --git a/src/lib/tauri-commands.ts b/src/lib/tauri-commands.ts index e96e09c..87b8349 100644 --- a/src/lib/tauri-commands.ts +++ b/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; -import type { AppConfig, Theme, AgentStatus } from './types'; +import type { AppConfig, Theme, AgentStatus, AgentKind } from './types'; export async function getConfig(): Promise { return invoke('get_config'); @@ -9,7 +9,7 @@ export async function setEnabled(enabled: boolean): Promise { return invoke('set_enabled', { enabled }); } -export async function setSelectedAgent(agent: 'codex' | 'antigravity'): Promise { +export async function setSelectedAgent(agent: AgentKind): Promise { return invoke('set_selected_agent', { agent }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index ae47aaa..5b2c6d7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -37,12 +37,15 @@ export interface Theme { dir: string; } +/** Supported agents (matches src-tauri AgentKind, serialized lowercase). */ +export type AgentKind = 'codex' | 'antigravity' | 'linear'; + export interface AppConfig { enabled: boolean; selectedThemeId: string; autoLaunchAgent: boolean; activeIdentifier: string | null; - selectedAgent: 'codex' | 'antigravity'; + selectedAgent: AgentKind; } export interface AgentStatus { diff --git a/web/index.html b/web/index.html index 0630b93..cd60745 100644 --- a/web/index.html +++ b/web/index.html @@ -7,8 +7,8 @@ - - + +