From 270b5675c599bf792287b3611943441486908b6f Mon Sep 17 00:00:00 2001
From: link <511373265@qq.com>
Date: Sun, 20 Sep 2026 09:33:05 +0800
Subject: [PATCH 1/2] feat(pi.obsidian-theme): add the Obsidian dark theme
A deep blue-teal global theme tuned against a measured reference screenshot.
The decisive finding was hue, not lightness: the previous canvas had a
blue-minus-red lead of 7 (grey) while the reference sits at 21-24, so every
surface is now blue-dominant. A band scan also disproved the gradient theory:
the reference panels are flat (drift -0.11..-0.23 pts), so hierarchy is carried
by real 1px hairlines (#2A4054 / #1C2B3A) and a solid cyan-teal selection fill
(#1B4F53, +3.6 pts over a plain row) instead of a wash that measured as nothing.
Elevation ladder: #091219 inset, #0F1B26 app, #12202C rails, #0E1A25 reading
column, #182835 cards, #1B2C3B fields, #1E3040 raised, #223648 hover. Text:
#F2F7FA titles, #C4D2DD body (11.3:1), #8FA3B4 secondary, #6A7E8F hints.
Accent #5CD0B8 is reserved for selection, primary actions and focus.
Styling only: one global theme (CSS) plus a bilingual info panel. Permissions
are limited to ui.panel and ui.theme, with no network, filesystem, clipboard,
shell or agent access. The sheet carries no @import, no @font-face and no
external URL: its only reference is an inline data: SVG noise tile.
Also registers the plugin in both marketplace README tables and raises the
panel-chrome coverage assertion from 19 to 20 panel plugins.
---
README.md | 1 +
README.zh-CN.md | 1 +
plugins/pi.obsidian-theme/.gitattributes | 6 +
plugins/pi.obsidian-theme/README.md | 104 ++++
plugins/pi.obsidian-theme/main.js | 27 +
plugins/pi.obsidian-theme/manifest.json | 69 +++
.../renderer/appearance-boot.js | 104 ++++
.../pi.obsidian-theme/renderer/appearance.js | 231 ++++++++
.../renderer/capsule-retint.js | 44 ++
plugins/pi.obsidian-theme/renderer/index.html | 258 +++++++++
plugins/pi.obsidian-theme/renderer/panel.js | 133 +++++
plugins/pi.obsidian-theme/themes/obsidian.css | 540 ++++++++++++++++++
tests/obsidian-theme.test.mjs | 93 +++
tests/panel-chrome.test.mjs | 2 +-
14 files changed, 1612 insertions(+), 1 deletion(-)
create mode 100644 plugins/pi.obsidian-theme/.gitattributes
create mode 100644 plugins/pi.obsidian-theme/README.md
create mode 100644 plugins/pi.obsidian-theme/main.js
create mode 100644 plugins/pi.obsidian-theme/manifest.json
create mode 100644 plugins/pi.obsidian-theme/renderer/appearance-boot.js
create mode 100644 plugins/pi.obsidian-theme/renderer/appearance.js
create mode 100644 plugins/pi.obsidian-theme/renderer/capsule-retint.js
create mode 100644 plugins/pi.obsidian-theme/renderer/index.html
create mode 100644 plugins/pi.obsidian-theme/renderer/panel.js
create mode 100644 plugins/pi.obsidian-theme/themes/obsidian.css
create mode 100644 tests/obsidian-theme.test.mjs
diff --git a/README.md b/README.md
index f0c3ed4..269380d 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,7 @@ Sources in this repository:
| **pi.workspace-file-guard** | Keeps model-written test, log, cache and temp files off the system drive, Desktop and Downloads; junk stays in the project's Temp or scratch | xingleiwu |
| **pi.goal-x** | Persistent workspace goals, task evidence and host-owned completion audits | Goal X contributors |
| **pi.parchment** | Warm parchment global theme: cream paper background with a faint grid, ink user bubbles, paper assistant cards and monospace meta lines (styling only) | pkmcenter |
+| **pi.obsidian-theme** | Deep blue-teal global theme tuned against a measured reference: a layered surface ladder, hierarchy from 1px hairlines rather than glow, a solid teal selection fill and a four-step text scale (styling only) | ily55421 |
Published on the plugin center from their own repositories:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 83e0215..3c639be 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -50,6 +50,7 @@
| **pi.workspace-file-guard** | C盘防垃圾:防止模型把测试、日志、缓存、临时文件写到系统盘、桌面、下载,垃圾只待在当前项目的 Temp 或 scratch | xingleiwu |
| **pi.goal-x** | 为 PI-Desktop 提供持久化工作区目标、任务证据与宿主完成审计 | Goal X contributors |
| **pi.parchment** | 羊皮纸主题:米色纸面背景配淡网格,墨色用户气泡,纸色助手卡片,等宽字体元信息行(纯样式) | pkmcenter |
+| **pi.obsidian-theme** | 黑曜石主题:深蓝青全局主题,按实测参考图校准——分层表面阶梯、用 1px 发丝边框而非发光建立层级、实心青绿选中态、四档文字层级(纯样式) | ily55421 |
插件中心上由作者自有仓库发布:
diff --git a/plugins/pi.obsidian-theme/.gitattributes b/plugins/pi.obsidian-theme/.gitattributes
new file mode 100644
index 0000000..ab63a06
--- /dev/null
+++ b/plugins/pi.obsidian-theme/.gitattributes
@@ -0,0 +1,6 @@
+manifest.json text eol=lf
+main.js text eol=lf
+README.md text eol=lf
+themes/*.css text eol=lf
+renderer/*.html text eol=lf
+renderer/*.js text eol=lf
diff --git a/plugins/pi.obsidian-theme/README.md b/plugins/pi.obsidian-theme/README.md
new file mode 100644
index 0000000..0a2c51b
--- /dev/null
+++ b/plugins/pi.obsidian-theme/README.md
@@ -0,0 +1,104 @@
+# Obsidian Theme / 黑曜石主题
+
+A deep blue-grey global theme for PI-Desktop, tuned for long reading: a layered surface ladder instead of one flat black, hierarchy carried by 1px hairline borders rather than glow, a low-saturation teal accent, and a four-step text scale. Styling only — no features.
+
+PI-Desktop 深蓝灰全局主题,为长时间阅读调校:分层海拔而非一片死黑、用 1px 发丝边框而非发光建立层级、低饱和青绿强调色、四档文字层级。纯样式,无额外功能。
+
+## Palette / 色板
+
+| Role / 用途 | Value | | Role / 用途 | Value |
+|---|---|---|---|---|
+| App base / 应用底色 | `#0F1B26` | | Title text / 标题 | `#F2F7FA` |
+| Rails / 侧栏 | `#12202C` | | Body prose / 正文 | `#C4D2DD` |
+| Reading column / 阅读区 | `#0E1A25` | | Secondary / 次级 | `#8FA3B4` |
+| Card / composer / 卡片 | `#182835` | | Hints / 弱提示 | `#6A7E8F` |
+| Raised / 悬浮 | `#1E3040` | | Accent / 强调 | `#5CD0B8` |
+| Hover | `#223648` | | Border / 边框 | `#2A4054` |
+| Field / 输入框 | `#1B2C3B` | | Inset / 凹陷 | `#091219` |
+| Selected row / 选中态 | `#1B4F53`→`#15414A` | | Info / Warning / Danger | `#7FB4F0` `#E8C878` `#E78282` |
+
+No pure black, no pure white. / 不使用纯黑,不使用纯白。
+
+## What it does / 功能
+
+- One global theme, selectable in **Settings → Theme → search "Obsidian" / 设置 → 主题 → 搜索 "Obsidian"**.
+- **Elevation ladder.** Eight distinct surface tones from `#091219` (inset) through `#0F1B26` (app) to `#223648` (hover), so the three columns and the cards inside them stop merging. A card now separates from the canvas it sits on; in the previous build that measured zero.
+- **Hierarchy from borders, not glow.** Every panel, card, field and code block carries a 1px `#2A4054` / `#1C2B3A` edge. All bloom and drop-glow shadows were removed.
+- **Four text steps for reading comfort.** Titles `#F2F7FA`, body prose `#C4D2DD`, secondary `#8FA3B4`, hints `#6A7E8F`. Body prose dropped from 17:1 to 11.3:1 against its background — still AAA, far less glaring — while secondary text went slightly up.
+- **Accent used as a material, sparingly.** `#5CD0B8` on selection, the send button, `.btn-primary`, focus rings and the active session rail; nothing else.
+- **Fields read as fields.** Lighter than the canvas with a hairline edge, so the composer stays visible; selected rows get a solid teal fill (`#1B4F53`) rather than a wash.
+- Code cards and blockquotes sit on `#16242F`; blockquotes carry a `#5CD0B8` rule; tables get a real row seam; meta and path lines are monospace.
+- 海拔阶梯:从 `#091219`(凹陷)到 `#223648`(hover)共八档表面色,三栏与其内部卡片不再糊成一片。
+- 层级靠边框而非发光:所有面板、卡片、输入框、代码块都带 1px 实色发丝边(`#2A4054` / `#1C2B3A`),发光阴影全部移除。
+- 四档文字:标题 / 正文 / 次级 / 弱提示。正文对比度由 17:1 降到 11.3:1(仍是 AAA)不再刺眼,次级文字略有提升。
+- 强调色克制使用:仅选中态、发送按钮、主按钮、聚焦描边与当前会话条目。
+- 输入框比画布更亮并带发丝边,因此输入区不会再"消失";选中行用实心 `#1B4F53` 而非半透明罩色。
+- 代码卡片与引用块底色 `#16242F`,引用块配 `#5CD0B8` 竖条;表格补上行分隔线;元信息与路径行改用等宽字体。
+
+## Reverting / 恢复
+
+Pick Light / Dark / System in the theme picker — the plugin can stay installed without effect. Or uninstall the plugin.
+
+在主题选择器中切回浅色 / 深色 / 系统即可;插件保留安装也无副作用,或直接卸载。
+
+## Permissions / 权限
+
+| Permission | Why / 原因 |
+|---|---|
+| `ui.panel` | Bilingual info panel: palette reference and elevation preview / 双语说明面板:色板参考与海拔预览 |
+| `ui.theme` | Contribute the global theme / 贡献全局主题 |
+
+## Safety / 安全
+
+CSS is sanitized by the host on load (256 KiB cap, no `@import`, no markup, no scripts, no external URLs — only `data:` URLs and manifest-declared assets are allowed). This sheet carries exactly one reference: a base64 `data:` SVG noise tile. The plugin makes no network requests, reads no files, and stores no data.
+
+CSS 由宿主在加载时净化(上限 256 KiB,禁止 `@import`、标记、脚本与外部 URL,仅允许 `data:` URL 与清单声明的资源)。本样式表只含一个引用:一个 base64 的 `data:` SVG 噪点贴图。插件不发起网络请求、不读取文件、不存储数据。
+
+## Files / 文件
+
+| File | Purpose / 作用 |
+|---|---|
+| `manifest.json` | Identity, permissions, the `contributes.themes` entry / 标识、权限、主题贡献声明 |
+| `main.js` | Registers the one "open panel" command / 注册唯一的打开面板命令 |
+| `themes/obsidian.css` | The theme itself / 主题本体 |
+| `renderer/index.html` | Info panel / 说明面板 |
+| `renderer/panel.js` | Panel copy + locale switching / 面板文案与语言切换 |
+| `renderer/appearance-boot.js` | Shared appearance adapter, pre-paint / 共享外观适配器(首帧) |
+| `renderer/appearance.js` | Shared appearance adapter, live updates / 共享外观适配器(实时更新) |
+| `renderer/capsule-retint.js` | Retints the host window-control capsule / 重染宿主窗口控制胶囊 |
+
+## Notes on the 3.0.0 revision / 3.0.0 改版说明
+
+The 2.0.0 build was measured against a reference screenshot, and the finding was
+not about lightness at all — it was **hue**. Blue-minus-red on the 2.0.0 canvas
+was 7, i.e. grey; the reference sits at 21–24. Every surface now carries a real
+blue lead (measured 23 across the whole rendered frame).
+
+A band scan also **disproved the gradient theory**: the reference panels are
+essentially flat (drift −0.11…−0.23 pts), so hierarchy is carried instead by
+real 1px borders and by a confident selected fill. 2.0.0 used a 13% accent wash
+for selection, which measured as nothing; the solid `#1B4F53` gradient now lifts
++3.6 pts over a plain row, matching the reference's own +2.1.
+
+Fluorescent mint was replaced by a lower-chroma teal and confined to selection,
+primary actions and focus. Bloom shadows gave way to a 3–5% top light, grain was
+cut to 0.035, radius was pulled onto one 8/10/12 ladder, prose line-height was
+raised to 1.72, and the scrollbars — which the host builds out of
+`--ds-text-primary`, now near-white — were pinned back so they stop reading as
+bright bars.
+
+## Notes on the 2.0.0 revision / 2.0.0 改版说明
+
+The 1.0.0 build was measured against a real screenshot and two of its ideas were wrong:
+
+1. **Semi-transparent white tiles cannot separate dark surfaces.** `--ds-tile` at 3.5% white over a near-black canvas produced a card that was optically identical to the page it sat on. The fix is opaque step values plus solid 1px borders.
+2. **`soft-light` grain is a no-op on near-black.** Its effect scales with `backdrop × (1 − backdrop)`, which is ~0.04 at `#0a0e0c`. Grain is now a faint `overlay` film at 0.05 opacity, kept because it still breaks up large flat fills.
+
+Recessing the composer into a darker well also backfired — it simply disappeared. Fields are lighter than the canvas again.
+
+1.0.0 版本经真实截图测量后,发现两处判断失误:
+
+1. **半透明白色色块无法分隔深色表面**:近黑画布上叠 3.5% 白,卡片与页面在视觉上完全相同。改用不透明分档色值 + 实色 1px 边框。
+2. **`soft-light` 噪点在近黑背景上等于没有**:其强度与 `背景 × (1 − 背景)` 成正比,在 `#0a0e0c` 处仅约 0.04。现改为 0.05 不透明度的 `overlay` 薄膜,保留它是因为仍能打破大面积纯色。
+
+此外把输入框做成比画布更暗的"凹井"也适得其反——它直接看不见了,现已改回比画布更亮。
diff --git a/plugins/pi.obsidian-theme/main.js b/plugins/pi.obsidian-theme/main.js
new file mode 100644
index 0000000..5f44879
--- /dev/null
+++ b/plugins/pi.obsidian-theme/main.js
@@ -0,0 +1,27 @@
+/**
+ * Obsidian Theme — entry point.
+ *
+ * The theme itself is declarative: the host reads `contributes.themes` and
+ * injects `themes/obsidian.css` when the user picks it, so nothing has to be
+ * registered at runtime. The only runtime work is one command that opens the
+ * info panel, so the user can reach the palette reference from the command
+ * palette without hunting through Settings.
+ */
+const OPEN_COMMAND_ID = "obsidian-theme.open";
+
+async function onLoad() {
+ await pi.commands.register({
+ id: OPEN_COMMAND_ID,
+ title: "Obsidian Theme: Open info panel",
+ keywords: ["obsidian", "theme", "dark", "黑曜石", "主题", "深色"],
+ run: async () => {
+ await pi.ui.openPanel();
+ },
+ });
+}
+
+async function onUnload() {
+ await pi.commands.unregister(OPEN_COMMAND_ID);
+}
+
+module.exports = { onLoad, onUnload };
diff --git a/plugins/pi.obsidian-theme/manifest.json b/plugins/pi.obsidian-theme/manifest.json
new file mode 100644
index 0000000..cc7bf65
--- /dev/null
+++ b/plugins/pi.obsidian-theme/manifest.json
@@ -0,0 +1,69 @@
+{
+ "schemaVersion": 1,
+ "id": "pi.obsidian-theme",
+ "name": "Obsidian Theme",
+ "version": "3.0.0",
+ "description": "A deep blue-teal global theme tuned to a measured reference: surfaces lifted from #0F1B26 to #223648, hierarchy carried by 1px #2A4054 hairlines rather than glow, a solid #1B4F53 selection state, and a four-step text scale that takes body prose from 17:1 down to 11:1. Styling only — no features.",
+ "i18n": {
+ "en": {
+ "name": "Obsidian Theme",
+ "description": "A deep blue-teal global theme tuned to a measured reference: surfaces lifted from #0F1B26 to #223648, hierarchy carried by 1px #2A4054 hairlines rather than glow, a solid #1B4F53 selection state, and a four-step text scale that takes body prose from 17:1 down to 11:1. Styling only — no features.",
+ "safetyNotes": "Pure styling. The plugin contributes one global theme (CSS only) and a small info panel. No network, file, clipboard, shell, or agent access."
+ },
+ "zh-CN": {
+ "name": "黑曜石主题",
+ "description": "深蓝青全局主题,按实测参考图校准:表面由 #0F1B26 分层抬至 #223648,层级用 1px #2A4054 发丝边框而非发光承担,选中态为实心 #1B4F53,四档文字层级把正文对比度从 17:1 降到 11:1。除样式外不提供任何功能。",
+ "safetyNotes": "纯样式插件。仅贡献一个全局主题(只有 CSS)和一个说明面板。无网络、文件、剪贴板、命令执行或智能体权限。"
+ }
+ },
+ "author": "ily55421",
+ "license": "MIT",
+ "main": "main.js",
+ "ui": {
+ "panel": "renderer/index.html",
+ "width": 480,
+ "height": 620,
+ "title": {
+ "en": "Obsidian Theme",
+ "zh-CN": "黑曜石主题"
+ }
+ },
+ "contributes": {
+ "themes": [
+ {
+ "id": "obsidian",
+ "label": "Obsidian Dark",
+ "base": "dark",
+ "path": "themes/obsidian.css"
+ }
+ ],
+ "commands": [
+ {
+ "id": "obsidian-theme.open",
+ "title": "Obsidian Theme: Open info panel",
+ "keywords": [
+ "obsidian",
+ "theme",
+ "dark",
+ "黑曜石",
+ "主题",
+ "深色"
+ ],
+ "category": "Theme"
+ }
+ ]
+ },
+ "categories": [
+ "theme",
+ "community"
+ ],
+ "safetyNotes": "Pure styling. The plugin contributes one global theme (CSS only) and a small info panel. No network, file, clipboard, shell, or agent access.",
+ "changelog": "Release 3.0.0: retuned against a measured reference screenshot. The decisive finding was hue, not lightness — the 2.0.0 canvas had a blue-minus-red lead of 7 (grey), while the reference sits at 21-24; every surface is now blue-dominant (measured 23 across the frame). A band scan also disproved the gradient theory: the reference panels are essentially flat (drift -0.11..-0.23 pts), so hierarchy is carried by real 1px borders (#2A4054 / #1C2B3A, which rise 1.0-2.8 pts off their neighbours) and by a solid cyan-teal selection fill (#1B4F53, +3.6 pts over a plain row) instead of the 13% wash 2.0.0 used, which measured as nothing. Ladder: #091219 inset, #0F1B26 app, #12202C rails, #0E1A25 reading column, #182835 cards, #1B2C3B fields, #1E3040 raised, #223648 hover. Text: #F2F7FA titles, #C4D2DD body (11.4:1, down from 17.1:1), #8FA3B4 secondary, #6A7E8F hints. Accent lowered from #65D6BC to #5CD0B8 and reserved for selection, primary actions and focus. All bloom removed in favour of a 3-5% top light; grain cut to 0.035; radius pulled onto one 8/10/12 ladder; prose line-height raised to 1.72; scrollbars retuned. Release 2.0.0: blue-grey rebalance after a screenshot review. Release 1.0.0: first release.",
+ "permissions": [
+ "ui.panel",
+ "ui.theme"
+ ],
+ "engines": {
+ "piDesktop": ">=0.15.0"
+ }
+}
diff --git a/plugins/pi.obsidian-theme/renderer/appearance-boot.js b/plugins/pi.obsidian-theme/renderer/appearance-boot.js
new file mode 100644
index 0000000..b4dba34
--- /dev/null
+++ b/plugins/pi.obsidian-theme/renderer/appearance-boot.js
@@ -0,0 +1,104 @@
+/**
+ * Appearance pre-paint boot — copy into a plugin's renderer/ and include
+ * synchronously in
(before the body) so the first painted frame is
+ * already in the app's palette and language direction.
+ *
+ * The host's real appearance arrives asynchronously through the panel bridge
+ * (`app.getAppearance`), one round-trip too late to avoid a flash, so the last
+ * known *app* appearance is cached in localStorage and replayed here.
+ *
+ * Never follow the OS color scheme for the live palette. The app can be dark
+ * while the OS is light (or the reverse); resolving "system" against the OS
+ * fights the host and flashes.
+ *
+ * Usage (in the plugin's index.html , before other scripts):
+ *
+ *
+ */
+(function () {
+ "use strict";
+
+ var DEFAULT_KEY = "pi.appearance.v1";
+ var CACHE_KEY =
+ (typeof window.__APPEARANCE_CACHE_KEY === "string" &&
+ window.__APPEARANCE_CACHE_KEY) ||
+ DEFAULT_KEY;
+ var root = document.documentElement;
+
+ function readCache() {
+ try {
+ var raw = window.localStorage.getItem(CACHE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ function currentTheme() {
+ var theme = root.getAttribute("data-theme");
+ return theme === "light" || theme === "dark" ? theme : "";
+ }
+
+ /**
+ * Only an explicit light/dark value counts. "system" and missing values keep
+ * the current app theme (or stay unset) — they must not snap to the OS.
+ */
+ function resolveBase(base) {
+ if (base === "light" || base === "dark") return base;
+ return currentTheme();
+ }
+
+ function resolveLocale(locale) {
+ return String(locale || "").toLowerCase().startsWith("zh") ? "zh-CN" : "en";
+ }
+
+ function explicitBase(appearance) {
+ if (!appearance || typeof appearance !== "object") return "";
+ if (appearance.base === "light" || appearance.base === "dark") return appearance.base;
+ if (appearance.theme === "light" || appearance.theme === "dark") return appearance.theme;
+ return "";
+ }
+
+ function applyAppearance(appearance) {
+ var resolved = appearance || {};
+ var base = explicitBase(resolved) || resolveBase(resolved.base);
+ var locale = resolveLocale(resolved.locale);
+
+ if (base === "light" || base === "dark") {
+ root.dataset.theme = base;
+ }
+
+ root.dataset.lang = locale === "zh-CN" ? "zh" : "en";
+ root.lang = locale;
+
+ var style = document.getElementById("pi-appearance-theme-css");
+ if (resolved.pluginThemeCss) {
+ if (!style) {
+ style = document.createElement("style");
+ style.id = "pi-appearance-theme-css";
+ document.head.appendChild(style);
+ }
+ style.textContent = resolved.pluginThemeCss;
+ } else if (style) {
+ style.remove();
+ }
+
+ return { base: base || currentTheme() || null, locale: locale, raw: resolved };
+ }
+
+ var cached = readCache();
+ if (cached && (cached.base === "light" || cached.base === "dark" || cached.locale)) {
+ applyAppearance(cached);
+ }
+ // No cache: leave data-theme unset until the host answers. CSS :root defaults
+ // cover the first frame; do not paint the OS palette.
+
+ window.__appearanceBoot = {
+ cacheKey: CACHE_KEY,
+ cached: cached,
+ applyAppearance: applyAppearance,
+ resolveBase: resolveBase,
+ resolveLocale: resolveLocale,
+ explicitBase: explicitBase,
+ };
+})();
diff --git a/plugins/pi.obsidian-theme/renderer/appearance.js b/plugins/pi.obsidian-theme/renderer/appearance.js
new file mode 100644
index 0000000..7d55c30
--- /dev/null
+++ b/plugins/pi.obsidian-theme/renderer/appearance.js
@@ -0,0 +1,231 @@
+/**
+ * Appearance runtime — copy into a plugin's renderer/ and load after the body
+ * (defer or end-of-body) so the boot script has already painted the first frame.
+ *
+ * Wires the panel to the host's official appearance channel:
+ * - `bridge.invoke("app.getAppearance")` reads the app's current palette,
+ * language and active plugin theme;
+ * - `bridge.on("appearance:changed", ...)` re-applies live when the app
+ * switches theme or language;
+ * - a slow poll covers docked views that miss a push;
+ * - the resolved appearance is cached (same key as the boot script) so the
+ * next open paints correctly before any script runs.
+ *
+ * Follow the *app* palette only. Never resolve "system" / missing base against
+ * `prefers-color-scheme`: when the app is dark and the OS is light (or the
+ * reverse), that fight flashes the UI every poll.
+ *
+ * Exposed as window.__appearance:
+ * init(bridge) — start reading + subscribing (call once)
+ * current() — { base, locale, raw }
+ * onThemeChange(fn) — fn(base) on palette changes
+ * onLocaleChange(fn) — fn(locale) on language changes
+ * setThemeOverride(base|null) — force "light"/"dark", or null to follow
+ * the app again (for in-panel theme toggles)
+ */
+(function () {
+ "use strict";
+
+ var boot = window.__appearanceBoot || null;
+ var CACHE_KEY = boot ? boot.cacheKey : "pi.appearance.v1";
+ var POLL_MS = 2000;
+
+ var state = {
+ base: null,
+ locale: null,
+ raw: null,
+ themeOverride: null,
+ started: false,
+ };
+ var themeListeners = [];
+ var localeListeners = [];
+ var pollTimer = null;
+ var lastFingerprint = "";
+
+ function writeCache(entry) {
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify(entry));
+ } catch (error) {
+ /* cache is best-effort */
+ }
+ }
+
+ function documentTheme() {
+ try {
+ var theme = document.documentElement.getAttribute("data-theme");
+ return theme === "light" || theme === "dark" ? theme : "";
+ } catch (error) {
+ return "";
+ }
+ }
+
+ /** Explicit app light/dark only — never the OS. */
+ function explicitBase(entry) {
+ if (!entry || typeof entry !== "object") return "";
+ if (typeof boot?.explicitBase === "function") return boot.explicitBase(entry) || "";
+ if (entry.base === "light" || entry.base === "dark") return entry.base;
+ if (entry.theme === "light" || entry.theme === "dark") return entry.theme;
+ return "";
+ }
+
+ function normalizeLocale(value) {
+ return boot ? boot.resolveLocale(value) : String(value || "").toLowerCase().startsWith("zh") ? "zh-CN" : "en";
+ }
+
+ function pluginThemeCss(entry) {
+ if (!entry || typeof entry !== "object") return null;
+ if (entry.pluginThemeCss) return entry.pluginThemeCss;
+ if (entry.pluginTheme && typeof entry.pluginTheme === "object") return entry.pluginTheme.css || null;
+ return null;
+ }
+
+ function fingerprint(entry) {
+ if (!entry || typeof entry !== "object") return "";
+ var css = pluginThemeCss(entry);
+ return [
+ explicitBase(entry) || entry.base,
+ entry.theme,
+ entry.locale,
+ css ? String(css).length : 0,
+ entry.pluginTheme && entry.pluginTheme.id,
+ ].join("|");
+ }
+
+ function notify(list, value) {
+ for (var i = 0; i < list.length; i += 1) {
+ try {
+ list[i](value);
+ } catch (error) {
+ /* a listener must not break appearance handling */
+ }
+ }
+ }
+
+ /** Apply a host appearance (or an override) and notify listeners. */
+ function apply(entry) {
+ var resolved = entry || {};
+ var incoming = explicitBase(resolved);
+ var base =
+ state.themeOverride ||
+ incoming ||
+ state.base ||
+ documentTheme();
+ if (base !== "light" && base !== "dark") base = null;
+ var locale = normalizeLocale(resolved.locale);
+ var css = pluginThemeCss(resolved);
+ var themeChanged = base && base !== state.base;
+ var localeChanged = locale !== state.locale;
+
+ if (base) state.base = base;
+ state.locale = locale;
+ state.raw = resolved;
+ lastFingerprint = fingerprint(resolved);
+
+ if (boot) {
+ var applied = boot.applyAppearance({
+ base: state.themeOverride || incoming || state.base,
+ locale: resolved.locale,
+ pluginThemeCss: state.themeOverride === null && css ? css : null,
+ });
+ if (applied.base === "light" || applied.base === "dark") state.base = applied.base;
+ state.locale = applied.locale;
+ } else if (base) {
+ document.documentElement.dataset.theme = base;
+ document.documentElement.dataset.lang = locale === "zh-CN" ? "zh" : "en";
+ document.documentElement.lang = locale;
+ }
+
+ if (state.base === "light" || state.base === "dark") {
+ writeCache({
+ base: state.base,
+ locale: state.locale,
+ pluginThemeCss: state.themeOverride === null && css ? css : undefined,
+ });
+ }
+
+ if (themeChanged) notify(themeListeners, state.base);
+ if (localeChanged) notify(localeListeners, state.locale);
+ return state;
+ }
+
+ function ingest(appearance) {
+ if (!appearance || typeof appearance !== "object") return;
+ // Ignore OS-fallback payloads (`base: "system"` with no explicit theme).
+ // Applying those snaps the UI to prefers-color-scheme and fights the app.
+ if (!explicitBase(appearance) && !appearance.locale && !pluginThemeCss(appearance)) return;
+ if (!explicitBase(appearance) && !state.base && !documentTheme()) {
+ // Locale/css-only update with no palette yet: keep waiting for the app.
+ if (appearance.locale) {
+ var locale = normalizeLocale(appearance.locale);
+ if (locale !== state.locale) {
+ state.locale = locale;
+ notify(localeListeners, locale);
+ }
+ }
+ return;
+ }
+ var next = fingerprint(appearance);
+ if (next && next === lastFingerprint) return;
+ apply(appearance);
+ }
+
+ function pull(bridge) {
+ if (!bridge || typeof bridge.invoke !== "function") return;
+ if (typeof document !== "undefined" && document.hidden) return;
+ bridge.invoke("app.getAppearance").then(ingest).catch(function () {
+ // Missing channel: keep the last app theme. Do not snap to the OS.
+ });
+ }
+
+ function startPoll(bridge) {
+ if (pollTimer) return;
+ pollTimer = setInterval(function () {
+ pull(bridge);
+ }, POLL_MS);
+ if (typeof document !== "undefined" && document.addEventListener) {
+ document.addEventListener("visibilitychange", function () {
+ if (!document.hidden) pull(bridge);
+ });
+ }
+ }
+
+ function init(bridge) {
+ if (state.started) return;
+ state.started = true;
+ if (!bridge || typeof bridge.invoke !== "function") return;
+
+ pull(bridge);
+
+ if (typeof bridge.on === "function") {
+ try {
+ bridge.on("appearance:changed", ingest);
+ } catch (error) {
+ /* subscription is best-effort */
+ }
+ }
+ startPoll(bridge);
+ }
+
+ function setThemeOverride(base) {
+ var next = base === "light" || base === "dark" ? base : null;
+ if (next === state.themeOverride) return;
+ state.themeOverride = next;
+ apply(state.raw || {});
+ }
+
+ window.__appearance = {
+ init: init,
+ apply: apply,
+ current: function () {
+ return { base: state.base, locale: state.locale, raw: state.raw };
+ },
+ onThemeChange: function (fn) {
+ themeListeners.push(fn);
+ },
+ onLocaleChange: function (fn) {
+ localeListeners.push(fn);
+ },
+ setThemeOverride: setThemeOverride,
+ __state: state,
+ };
+})();
diff --git a/plugins/pi.obsidian-theme/renderer/capsule-retint.js b/plugins/pi.obsidian-theme/renderer/capsule-retint.js
new file mode 100644
index 0000000..bf70e2a
--- /dev/null
+++ b/plugins/pi.obsidian-theme/renderer/capsule-retint.js
@@ -0,0 +1,44 @@
+(function () {
+ "use strict";
+
+ function paintable(value) {
+ return value && value !== "transparent" && value !== "rgba(0, 0, 0, 0)";
+ }
+
+ function retint() {
+ var host = document.querySelector("pi-plugin-panel-chrome");
+ if (!host) return;
+ var bodyStyle = document.body ? getComputedStyle(document.body) : null;
+ var rootStyle = getComputedStyle(document.documentElement);
+ var background = bodyStyle && bodyStyle.backgroundColor;
+ var foreground = bodyStyle && bodyStyle.color;
+ if (!paintable(background)) background = rootStyle.backgroundColor;
+ if (!paintable(foreground)) foreground = rootStyle.color;
+ if (paintable(background)) host.style.setProperty("--pi-plugin-panel-page-background", background);
+ if (paintable(foreground)) host.style.setProperty("--pi-plugin-panel-page-foreground", foreground);
+ }
+
+ var scheduled = false;
+ function schedule() {
+ if (scheduled) return;
+ scheduled = true;
+ window.setTimeout(function () {
+ scheduled = false;
+ retint();
+ }, 0);
+ }
+
+ if (typeof MutationObserver !== "undefined") {
+ new MutationObserver(schedule).observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["data-theme", "data-base", "style"],
+ });
+ }
+ if (document.readyState === "loading") {
+ window.addEventListener("DOMContentLoaded", schedule, { once: true });
+ } else {
+ schedule();
+ }
+})();
diff --git a/plugins/pi.obsidian-theme/renderer/index.html b/plugins/pi.obsidian-theme/renderer/index.html
new file mode 100644
index 0000000..e6bd17f
--- /dev/null
+++ b/plugins/pi.obsidian-theme/renderer/index.html
@@ -0,0 +1,258 @@
+
+
+
+
+
+
+
+Obsidian Theme
+
+
+
+
+
+
+
O
+
+
Obsidian Theme
+
Deep blue-grey · teal accent
+
+
+
+
+
About
+
This plugin contributes one global theme (CSS only) and nothing else. Apply it in Settings → Theme and search Obsidian.
+
+
+
+
Elevation
+
+
App#0F1B26
+
Rail#12202C
+
Card#182835
+
Field#1B2C3B
+
Accent#5CD0B8
+
+
+
+
+
Apply & revert
+
+
Open Settings → Theme and search Obsidian.
+
Pick Obsidian Dark. The whole app repaints instantly.
+
To revert, choose Light, Dark or System — the plugin can stay installed with no effect.
+
+
+
+
+
Palette
+
+
App background#0f1b26
+
Sidebar rail#12202C
+
Card / composer#182835
+
Border#2A4054
+
Title text#F2F7FA
+
Body text#C4D2DD
+
Secondary text#8FA3B4
+
Teal accent#5CD0B8
+
Info#7FB4F0
+
Warning#E8C878
+
Danger#E78282
+
+
+
+
Pure styling: no network, file, clipboard, shell or agent access.
+
+
+
+
+
+
+
+
+
diff --git a/plugins/pi.obsidian-theme/renderer/panel.js b/plugins/pi.obsidian-theme/renderer/panel.js
new file mode 100644
index 0000000..486f63f
--- /dev/null
+++ b/plugins/pi.obsidian-theme/renderer/panel.js
@@ -0,0 +1,133 @@
+/**
+ * Obsidian Theme — info panel.
+ *
+ * Read-only documentation page: it applies the localized strings and follows
+ * the app's light/dark palette through the shared appearance adapter. It calls
+ * no host API beyond the appearance channel, so `ui.panel` is the only
+ * permission it needs.
+ */
+(function () {
+ "use strict";
+
+ var STRINGS = {
+ en: {
+ title: "Obsidian Theme",
+ subtitle: "Deep blue-grey · teal accent",
+ aboutHeading: "About",
+ about:
+ "This plugin contributes one global theme (CSS only) and nothing else. Apply it in Settings → Theme and search Obsidian.",
+ ladderHeading: "Elevation",
+ applyHeading: "Apply & revert",
+ apply1: "Open Settings → Theme and search Obsidian.",
+ apply2: "Pick Obsidian Dark. The whole app repaints instantly.",
+ apply3:
+ "To revert, choose Light, Dark or System — the plugin can stay installed with no effect.",
+ paletteHeading: "Palette",
+ safety: "Pure styling: no network, file, clipboard, shell or agent access.",
+ names: {
+ app: "App background",
+ rail: "Sidebar rail",
+ card: "Card / composer",
+ border: "Border",
+ title: "Title text",
+ body: "Body text",
+ dim: "Secondary text",
+ accent: "Teal accent",
+ info: "Info",
+ warn: "Warning",
+ danger: "Danger",
+ },
+ },
+ "zh-CN": {
+ title: "黑曜石主题",
+ subtitle: "深蓝灰 · 青绿强调",
+ aboutHeading: "关于",
+ about:
+ "本插件只贡献一个全局主题(仅 CSS),不提供其他功能。在「设置 → 主题」中搜索「Obsidian」即可应用。",
+ ladderHeading: "海拔层级",
+ applyHeading: "应用与恢复",
+ apply1: "打开「设置 → 主题」,搜索 Obsidian。",
+ apply2: "选择「Obsidian Dark」,整个界面会立即换色。",
+ apply3:
+ "恢复:切回「浅色 / 深色 / 系统」即可,插件可以保留安装且不产生任何影响。",
+ paletteHeading: "色板",
+ safety: "纯样式插件:无网络、文件、剪贴板、命令执行或智能体权限。",
+ names: {
+ app: "应用底色",
+ rail: "侧栏",
+ card: "卡片 / 输入框",
+ border: "边框",
+ title: "标题文字",
+ body: "正文文字",
+ dim: "次级文字",
+ accent: "青绿强调色",
+ info: "信息",
+ warn: "提醒",
+ danger: "异常",
+ },
+ },
+ };
+
+ var SWATCH_IDS = {
+ app: "c-app",
+ rail: "c-rail",
+ card: "c-card",
+ border: "c-border",
+ title: "c-title",
+ body: "c-body",
+ dim: "c-dim",
+ accent: "c-accent",
+ info: "c-info",
+ warn: "c-warn",
+ danger: "c-danger",
+ };
+
+ function setText(id, value) {
+ var node = document.getElementById(id);
+ // textContent, never innerHTML: this page renders no author-controlled
+ // markup and must not become an injection sink.
+ if (node && typeof value === "string") node.textContent = value;
+ }
+
+ function locale() {
+ if (window.__appearance && typeof window.__appearance.current === "function") {
+ var current = window.__appearance.current();
+ if (current && current.locale) return current.locale;
+ }
+ return navigator.language || "en";
+ }
+
+ function apply() {
+ var table = String(locale()).toLowerCase().indexOf("zh") === 0
+ ? STRINGS["zh-CN"]
+ : STRINGS.en;
+
+ setText("t-title", table.title);
+ setText("t-subtitle", table.subtitle);
+ setText("t-about-h", table.aboutHeading);
+ setText("t-about", table.about);
+ setText("t-ladder-h", table.ladderHeading);
+ setText("t-apply-h", table.applyHeading);
+ setText("t-apply-1", table.apply1);
+ setText("t-apply-2", table.apply2);
+ setText("t-apply-3", table.apply3);
+ setText("t-palette-h", table.paletteHeading);
+ setText("t-safety", table.safety);
+
+ for (var key in SWATCH_IDS) {
+ if (Object.prototype.hasOwnProperty.call(SWATCH_IDS, key)) {
+ setText(SWATCH_IDS[key], table.names[key]);
+ }
+ }
+
+ document.documentElement.lang =
+ table === STRINGS["zh-CN"] ? "zh-CN" : "en";
+ }
+
+ // Paint the localized copy first, then re-localize if the app switches
+ // language. Theme (light/dark) repainting is owned by appearance-boot.js.
+ apply();
+ if (window.__appearance && typeof window.__appearance.onLocaleChange === "function") {
+ window.__appearance.onLocaleChange(apply);
+ }
+})();
diff --git a/plugins/pi.obsidian-theme/themes/obsidian.css b/plugins/pi.obsidian-theme/themes/obsidian.css
new file mode 100644
index 0000000..7d40797
--- /dev/null
+++ b/plugins/pi.obsidian-theme/themes/obsidian.css
@@ -0,0 +1,540 @@
+/* Obsidian — deep blue-grey global theme for PI-Desktop (base: dark)
+ *
+ * Why this revision looks the way it does. The previous build was measured
+ * against a real screenshot and the numbers say the approach was wrong in one
+ * specific way: the palette was *grey*, not blue. Blue-minus-red on the 2.0.0
+ * canvas was 7; the reference sits at 21–35. That single fact accounts for most
+ * of the "cheap flat black" impression.
+ *
+ * Measured from the reference (band scans, not single pixels):
+ * column fills #0f1c28 / #0f1b27 / #0e1924 — near-identical tone
+ * in-panel gradient drift -0.11..-0.23 pts, i.e. FLAT. Only a header lift
+ * of ~0.5 pts exists. A gradient was never the mechanism.
+ * column seams a 1px line rising 1.03 / 2.01 pts over both neighbours
+ * selected row #1b4f53 = rgb(27,79,83), +2.11 pts over a plain row,
+ * green +52 and blue +56 over red: a solid cyan-teal fill
+ * composer / card #182835, +0.89 pts above the columns
+ * secondary text #758b9e..#8599aa, 4.7..5.9:1
+ *
+ * So: separation comes from blue saturation, real 1px borders, and a confident
+ * selected fill — not from spreading surfaces far apart in lightness, and not
+ * from glow. Everything below is built to those four mechanisms.
+ *
+ * Ladder (L = WCAG relative luminance, blue-lead = B−R):
+ * #091219 inset 0.56% · #0F1B26 app 1.03% · #12202C rail 1.34%
+ * #182835 card 1.97% · #1B2C3B field 2.35% · #1E3040 raised 2.76%
+ * #223648 hover 3.45%
+ * No pure black, no pure white anywhere.
+ */
+
+:root[data-theme="dark"] {
+ /* ---- surfaces ------------------------------------------------------- */
+ --ds-bg-inset: #091219;
+ --ds-bg-under: #0F1B26;
+ --ds-bg-primary: #0F1B26;
+ --ds-settings-rail-bg: #12202C;
+ --ds-bg-sidebar: #12202C;
+ --ds-bg-dock: #12202C;
+ --ds-bg-secondary: #182835;
+ --ds-bg-tertiary: #1E3040;
+ --ds-bg-dock-raised: #182835;
+ --ds-bg-elevated: rgba(24, 40, 53, 0.96);
+ --ds-bg-elevated-opaque: #182835;
+ --ds-bg-elevated-primary: #182835;
+ --ds-bg-composer: #182835;
+ --ds-bg-hover: #223648;
+ --ds-bg-active: #1B4F53;
+ --ds-bg-chip: rgba(196, 210, 221, 0.06);
+ --ds-field-inset-bg: rgba(196, 210, 221, 0.055);
+ --ds-field-inset-focus-bg: rgba(196, 210, 221, 0.08);
+ --ds-settings-field-bg: #182835;
+ --ds-settings-nav-active: rgba(92, 208, 184, 0.14);
+
+ /* The host paints the rails through its own image hook. A ~0.5 pt top lift
+ is all the reference actually carries — any more and it reads as a poster. */
+ --ds-bg-sidebar-image: linear-gradient(180deg, #17283A 0%, #12202C 42%, #111E29 100%);
+ --ds-sidebar-glass-tint: rgba(18, 32, 44, 0.55);
+ --ds-sidebar-glass-sheen-top: rgba(160, 205, 245, 0.055);
+ --ds-sidebar-glass-sheen-bottom: rgba(255, 255, 255, 0.02);
+
+ /* A handful of host controls bypass the token layer and read the raw grey
+ ramp directly — `.composer-input`, `.composer-toolbar .icon-btn` and
+ `.send-btn` all use `--gray-0`, which is #fff on the built-in dark base.
+ Re-point the ramp so those follow the palette instead of glaring. */
+ --gray-0: #F2F7FA;
+ --gray-50: #E6EDF3;
+ --gray-75: #DAE4EB;
+ --gray-100: #C4D2DD;
+ --gray-300: #8FA3B4;
+ --gray-500: #74889A;
+ --gray-550: #6A7E8F;
+ --gray-600: #5C7080;
+ --gray-700: #475B6B;
+ --gray-750: #1E3040;
+ --gray-800: #182835;
+ --gray-900: #0F1B26;
+ --gray-1000: #091219;
+
+ /* ---- text: four steps, none of them pure white ---------------------- */
+ --ds-text-primary: #F2F7FA; /* headings, icons, emphasis */
+ --ds-text-secondary: #C4D2DD; /* body prose — 11.3:1, not 17:1 */
+ --ds-text-muted: #8FA3B4; /* metadata, still AA at 6.7:1 */
+ --ds-text-faint: #6A7E8F; /* hints and timestamps only */
+ --ds-placeholder-ink: rgba(143, 163, 180, 0.8);
+ --ds-prose-kbd-fg: #C4D2DD;
+
+ /* ---- borders: the actual hierarchy carrier -------------------------- */
+ --ds-border-default: #2A4054;
+ --ds-border-subtle: #1C2B3A;
+ --ds-border-strong: #3A546B;
+ --ds-elevation-stroke: 0 0 0 1px #2A4054;
+ --ds-tile: rgba(196, 210, 221, 0.04);
+ --ds-tile-hover: rgba(196, 210, 221, 0.07);
+ --ds-tile-deep: rgba(92, 208, 184, 0.1);
+ --ds-tool-row-bg: rgba(196, 210, 221, 0.035);
+ --ds-thinking-code-bg: rgba(196, 210, 221, 0.05);
+
+ /* ---- raised and accent --------------------------------------------- */
+ --ds-raised: #1E3040;
+ --ds-raised-shadow: 0 1px 2px rgba(4, 9, 14, 0.5), 0 0 0 1px #2A4054;
+ --ds-accent: #5CD0B8;
+ --ds-accent-hover: #74DBC7;
+ --ds-accent-soft: #2F8C79;
+ --ds-focus: #5CD0B8;
+
+ /* ---- controls ------------------------------------------------------- */
+ --ds-switch-track-off: #2A4054;
+ --ds-switch-track-off-hover: #3A546B;
+ --ds-switch-ring-off: #4A6480;
+ --ds-switch-knob-off: #8FA3B4;
+ --ds-switch-knob-on: #0B161F;
+ --ds-send-disabled-bg: #24374A;
+ --ds-send-disabled-fg: #6A7E8F;
+
+ /* Radius discipline: buttons 8 / inputs 10 / cards 12. The host scale is
+ already 8/10/12 at --radius-xs/sm/md; only the two "-plus" steps drift off
+ it (14 and 18), so they are pulled back onto the ladder. */
+ --radius-md-plus: 12px;
+ --radius-lg-plus: 16px;
+ --ds-composer-radius: 10px;
+ --ds-composer-radius-lg: 12px;
+
+ /* ---- reading comfort: line-height and measure ----------------------- */
+ --leading-prose: 1.72;
+ --leading-chat: 1.66;
+ --leading-body: 1.52;
+ --leading-relaxed: 1.62;
+
+ /* ---- code, dialogs, status ------------------------------------------ */
+ --ds-code-head-bg: rgba(196, 210, 221, 0.045);
+ --ds-code-hover-bg: rgba(196, 210, 221, 0.075);
+ --ds-mermaid-canvas: #12202C;
+ --ds-scrim: rgba(4, 9, 14, 0.62);
+ --ds-modal-veil: rgba(15, 27, 38, 0.82);
+ --ds-shadow-dialog: 0 20px 48px rgba(4, 9, 14, 0.6);
+ /* Hairline ring plus one soft 2px lift. No bloom anywhere in this file. */
+ --ds-shadow-composer: 0 2px 8px rgba(4, 9, 14, 0.45), 0 0 0 1px #2A4054;
+ --ds-success: #5CD0B8;
+ --ds-warning: #E8C878;
+ --ds-error: #E78282;
+ --ds-info: #7FB4F0;
+
+ /* The reference type stack, prepended only: if a face is missing, Latin falls
+ back to the system UI face and CJK keeps its own family. */
+ --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
+ "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+ --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", "SF Mono", Menlo,
+ Consolas, "Liberation Mono", monospace;
+}
+
+/* ===========================================================================
+ Ambient light
+ The reference carries a cool blue lift in the upper-left and a faint teal
+ one to the right, both under a point of luminance. That is the "通透 / 科技
+ 质感" note in the brief — it is done with two wide, nearly invisible radial
+ washes rather than with any glow on a control.
+ =========================================================================== */
+:root[data-theme="dark"] body,
+:root[data-theme="dark"] #root,
+:root[data-theme="dark"] .app-shell {
+ background-color: var(--ds-bg-primary);
+ background-image:
+ radial-gradient(1100px 460px at 16% -14%, rgba(127, 180, 240, 0.075), transparent 62%),
+ radial-gradient(920px 420px at 84% -10%, rgba(92, 208, 184, 0.05), transparent 60%);
+ background-repeat: no-repeat, no-repeat;
+}
+
+/* The reading column sits a hair darker than the app base so the cards inside
+ it can lift off it, and carries its own ~0.4 pt top lift. */
+:root[data-theme="dark"] .chat-surface,
+:root[data-theme="dark"] .route-page {
+ background-color: #0E1A25;
+ background-image: linear-gradient(180deg, #12212E 0, #0E1A25 340px);
+}
+
+/* ===========================================================================
+ Column separation
+ Measured: the reference columns differ by 0.01–0.12 lightness points and
+ still read as three distinct areas. The work is done by a 1px line at each
+ seam, so that is what this is — not a bigger tone gap.
+ =========================================================================== */
+:root[data-theme="dark"] .sidebar-surface,
+:root[data-theme="dark"] .sidebar-rail {
+ box-shadow: inset -1px 0 0 #1C2B3A;
+}
+:root[data-theme="dark"] .work-panel {
+ box-shadow: inset 1px 0 0 #1C2B3A;
+}
+:root[data-theme="dark"] .main-titlebar,
+:root[data-theme="dark"] .conversation-topbar {
+ background-color: rgba(15, 27, 38, 0.86);
+ background-image: none;
+ border-bottom: 1px solid #1C2B3A;
+ backdrop-filter: blur(14px) saturate(1.1);
+ -webkit-backdrop-filter: blur(14px) saturate(1.1);
+}
+
+/* ===========================================================================
+ Film grain — deliberately low (brief: 低噪点)
+ Base64, not percent-encoding: the SVG payload holds a nested filter
+ reference and the host sanitizer counts url tokens against the references it
+ parses, so an encoded copy trips "malformed url reference".
+ =========================================================================== */
+:root[data-theme="dark"] body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ pointer-events: none;
+ opacity: 0.035;
+ mix-blend-mode: overlay;
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxODAnIGhlaWdodD0nMTgwJz48ZmlsdGVyIGlkPSduJz48ZmVUdXJidWxlbmNlIHR5cGU9J2ZyYWN0YWxOb2lzZScgYmFzZUZyZXF1ZW5jeT0nLjknIG51bU9jdGF2ZXM9JzMnIHN0aXRjaFRpbGVzPSdzdGl0Y2gnLz48L2ZpbHRlcj48cmVjdCB3aWR0aD0nMTgwJyBoZWlnaHQ9JzE4MCcgZmlsdGVyPSd1cmwoI24pJyBvcGFjaXR5PScuMTMnLz48L3N2Zz4=");
+ background-size: 180px 180px;
+ background-repeat: repeat;
+}
+
+/* ===========================================================================
+ Cards — one material, used everywhere
+ These are the host classes that paint a card out of `--ds-tile`, i.e. a
+ semi-transparent wash. On a deep base that is nearly invisible, which is the
+ same defect measured on the previous build. They get an opaque lifted surface,
+ a hairline and a 3.5% top light — same radius, same edge, same gloss, so the
+ app reads as one material instead of a pile of boxes.
+ `.review-change-card` is deliberately NOT here: it paints no background of its
+ own, so giving it one would invent a surface the host does not have.
+ =========================================================================== */
+:root[data-theme="dark"] .panel-card,
+:root[data-theme="dark"] .page-card,
+:root[data-theme="dark"] .asktool-card,
+:root[data-theme="dark"] .turn-outcome-card,
+:root[data-theme="dark"] .project-memory-card,
+:root[data-theme="dark"] .ext-transport-card,
+:root[data-theme="dark"] .subagent-detail-task-card,
+:root[data-theme="dark"] .provider-model-card-list,
+:root[data-theme="dark"] .mcpm-card,
+:root[data-theme="dark"] .sklm-card {
+ border: 1px solid #2A4054;
+ background-color: #182835;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 46%);
+ box-shadow: 0 1px 2px rgba(4, 9, 14, 0.4);
+}
+
+/* Settings panels: the host clears the background when the panel holds rows, so
+ mirror that condition instead of overriding it. */
+:root[data-theme="dark"] .settings-panel {
+ border: 1px solid #2A4054;
+ background-color: #182835;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 46%);
+}
+:root[data-theme="dark"] .settings-panel:has(> .settings-row) {
+ background-color: transparent;
+ background-image: none;
+ border-color: transparent;
+}
+
+/* ===========================================================================
+ Selection — a solid cyan-teal state, per the reference
+ Background lifted 2.1+ pts, a teal hairline, and the icon and label both
+ pick up the accent. A faint wash was tried in 2.0.0 and read as nothing.
+ =========================================================================== */
+:root[data-theme="dark"] .thread-item {
+ color: #9FB2C1;
+}
+:root[data-theme="dark"] .thread-item:hover {
+ color: #DCE7EF;
+ background: rgba(196, 210, 221, 0.055);
+}
+:root[data-theme="dark"] .thread-item.active {
+ color: #F2FBF8;
+ background: linear-gradient(180deg, #1B4F53 0%, #15414A 100%);
+ box-shadow: inset 0 0 0 1px rgba(92, 208, 184, 0.28),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+:root[data-theme="dark"] .thread-item.active svg {
+ color: #5CD0B8;
+}
+:root[data-theme="dark"] .thread-item.active .thread-item-title {
+ color: #F2FBF8;
+}
+
+/* Generic selected/active states, so the whole app agrees on one treatment. */
+:root[data-theme="dark"] .settings-theme-option.is-active,
+:root[data-theme="dark"] .settings-language-option.is-active,
+:root[data-theme="dark"] .nav-item.active,
+:root[data-theme="dark"] .work-panel-tab.active {
+ background: rgba(92, 208, 184, 0.13);
+ box-shadow: inset 0 0 0 1px rgba(92, 208, 184, 0.24);
+}
+
+/* ===========================================================================
+ Buttons — restrained micro-gloss, no neon
+ A 5% top light and a hairline give the "glass edge" the brief asks for; the
+ mint bloom from 2.0.0 is gone. `.btn` is transparent in the host (the
+ variants carry the fill), so only filled variants get a surface.
+ =========================================================================== */
+:root[data-theme="dark"] .btn {
+ border-radius: 8px;
+}
+:root[data-theme="dark"] .btn-secondary:not(:disabled) {
+ background-color: #1E3040;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 55%);
+ color: #C4D2DD;
+ box-shadow: inset 0 0 0 1px #2A4054;
+}
+:root[data-theme="dark"] .btn-secondary:not(:disabled):hover {
+ background-color: #223648;
+ color: #F2F7FA;
+ box-shadow: inset 0 0 0 1px #3A546B;
+}
+:root[data-theme="dark"] .btn-secondary:not(:disabled):active {
+ background-color: #16283A;
+ transform: translateY(1px);
+ box-shadow: inset 0 1px 3px rgba(4, 9, 14, 0.5), inset 0 0 0 1px #2A4054;
+}
+
+:root[data-theme="dark"] .btn-primary:not(:disabled) {
+ background-color: #5CD0B8;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 58%);
+ color: #0B161F;
+ box-shadow: inset 0 0 0 1px rgba(116, 219, 199, 0.5);
+}
+:root[data-theme="dark"] .btn-primary:not(:disabled):hover {
+ background-color: #74DBC7;
+ opacity: 1;
+}
+:root[data-theme="dark"] .btn-primary:not(:disabled):active {
+ background-color: #4CBBA5;
+ transform: translateY(1px);
+}
+
+/* The send button is the one control the host paints from `--gray-0` (white).
+ Solid teal, dark glyph, a top light, no bloom. */
+:root[data-theme="dark"] .send-btn:not(:disabled) {
+ background-color: #5CD0B8;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.2), transparent 58%);
+ border-color: #5CD0B8;
+ color: #0B161F;
+ box-shadow: inset 0 0 0 1px rgba(116, 219, 199, 0.55);
+}
+:root[data-theme="dark"] .send-btn:not(:disabled):hover {
+ opacity: 1;
+ background-color: #74DBC7;
+ border-color: #74DBC7;
+}
+:root[data-theme="dark"] .send-btn:not(:disabled):active {
+ background-color: #4CBBA5;
+ transform: scale(0.96);
+}
+
+:root[data-theme="dark"] .icon-btn:hover:not(:disabled),
+:root[data-theme="dark"] .copy-btn:hover,
+:root[data-theme="dark"] .sidebar-toolbar-button:hover,
+:root[data-theme="dark"] .title-nav-btn:hover:not(:disabled) {
+ background-color: #223648;
+ background-image: none;
+ box-shadow: inset 0 0 0 1px #2A4054;
+}
+
+/* ===========================================================================
+ Fields — lighter than the canvas, hairline edge, teal focus
+ 2.0.0 recessed them into a darker well and the composer effectively
+ disappeared; the reference has its input lifted ~0.9 pts instead.
+ =========================================================================== */
+:root[data-theme="dark"] .field-input,
+:root[data-theme="dark"] .field-textarea,
+:root[data-theme="dark"] .field-select {
+ border-radius: 10px;
+ background-color: #1B2C3B;
+ background-image: none;
+ color: #D6E2EB;
+ box-shadow: inset 0 0 0 1px #2A4054;
+}
+:root[data-theme="dark"] .field-input::placeholder,
+:root[data-theme="dark"] .field-textarea::placeholder {
+ color: #6A7E8F;
+}
+:root[data-theme="dark"] .field-input:focus,
+:root[data-theme="dark"] .field-textarea:focus,
+:root[data-theme="dark"] .field-select:focus {
+ background-color: #1E3040;
+ box-shadow: inset 0 0 0 1px rgba(92, 208, 184, 0.55);
+}
+
+:root[data-theme="dark"] .composer-shell {
+ background-color: #182835;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 40%);
+ box-shadow: var(--ds-shadow-composer);
+}
+:root[data-theme="dark"] .composer-shell:focus-within {
+ box-shadow: 0 2px 10px rgba(4, 9, 14, 0.5), 0 0 0 1px rgba(92, 208, 184, 0.45);
+}
+
+/* ===========================================================================
+ Prose — body at 11.3:1, not 17:1, with room to breathe
+ The host mixes prose colour out of `--ds-text-primary`, so the softer body
+ tone has to be set explicitly. Line-height and spacing come from the leading
+ tokens redefined above.
+ =========================================================================== */
+:root[data-theme="dark"] .prose-chat {
+ color: #C4D2DD;
+}
+:root[data-theme="dark"] .prose-chat p {
+ margin-top: 0.7em;
+ margin-bottom: 0.7em;
+}
+:root[data-theme="dark"] .prose-chat h1,
+:root[data-theme="dark"] .prose-chat h2,
+:root[data-theme="dark"] .prose-chat h3,
+:root[data-theme="dark"] .prose-chat h4,
+:root[data-theme="dark"] .prose-chat strong {
+ color: #F2F7FA;
+}
+:root[data-theme="dark"] .prose-chat h2 {
+ margin-top: 1.5em;
+}
+:root[data-theme="dark"] .prose-chat h3 {
+ margin-top: 1.3em;
+}
+:root[data-theme="dark"] .prose-chat li {
+ color: #C4D2DD;
+ margin-top: 0.3em;
+}
+:root[data-theme="dark"] .prose-chat a {
+ color: #5CD0B8;
+}
+
+/* Inline code is a chip. Scoped with `:not(pre) > code` because the plain
+ descendant form would put a chip behind every highlighted line of a code
+ block. */
+:root[data-theme="dark"] .prose-chat :not(pre) > code {
+ background: #1B2C3B;
+ color: #D6E2EB;
+ box-shadow: inset 0 0 0 1px #2A4054;
+ border-radius: 6px;
+}
+
+:root[data-theme="dark"] .prose-chat blockquote {
+ background: linear-gradient(180deg, rgba(92, 208, 184, 0.05), transparent), #16242F;
+ border-left: 3px solid #5CD0B8;
+ color: #AFC0CD;
+ border-radius: 0 10px 10px 0;
+}
+
+/* Tables: the host only tints alternate rows, which is invisible at this
+ contrast, so give the rows an actual seam. */
+:root[data-theme="dark"] .prose-chat th,
+:root[data-theme="dark"] .prose-chat td {
+ border-bottom: 1px solid #1C2B3A;
+}
+:root[data-theme="dark"] .prose-chat th {
+ color: #F2F7FA;
+}
+
+/* Code cards. The host reads the card background from its own `--code-block-bg`
+ custom property and pins the nested `pre` transparent, so overriding the
+ property is enough and the syntax palette keeps working on top. */
+:root[data-theme="dark"] .code-block {
+ --code-block-bg: #16242F;
+ border-radius: 12px;
+ color: #C4D2DD;
+ box-shadow: inset 0 0 0 1px #2A4054;
+}
+:root[data-theme="dark"] .code-block-head {
+ border-bottom: 1px solid #1C2B3A;
+}
+
+/* User bubble: a quiet teal-tinted panel, lifted off the reading column. */
+:root[data-theme="dark"] .message-row.user .message-bubble {
+ background: linear-gradient(180deg, rgba(92, 208, 184, 0.075), rgba(92, 208, 184, 0.045));
+ box-shadow: inset 0 0 0 1px rgba(92, 208, 184, 0.14);
+ color: #E8F0F6;
+}
+/* While editing, the host turns the bubble back into a plain field via
+ `:has(.message-edit)`, which ties the rule above on specificity. */
+:root[data-theme="dark"] .message-row.user .message-bubble:has(.message-edit) {
+ background: #1B2C3B;
+ box-shadow: inset 0 0 0 1px #2A4054;
+}
+
+:root[data-theme="dark"] .jump-latest-btn {
+ background-color: #1E3040;
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent 55%);
+ color: #C4D2DD;
+ box-shadow: 0 2px 8px rgba(4, 9, 14, 0.45), inset 0 0 0 1px #2A4054;
+}
+:root[data-theme="dark"] .jump-latest-btn:hover {
+ background-color: #223648;
+ color: #F2F7FA;
+}
+
+:root[data-theme="dark"] .empty-hero .empty-hero-icon {
+ color: #5CD0B8;
+}
+
+/* Meta, path and tool-name lines go monospace, as in the source design. */
+:root[data-theme="dark"] .message-meta,
+:root[data-theme="dark"] .message-meta-chip,
+:root[data-theme="dark"] .file-viewer-path,
+:root[data-theme="dark"] .tool-row-name {
+ font-family: var(--font-mono);
+}
+
+/* ===========================================================================
+ Scrollbars — thin and quiet. The host builds the thumb out of
+ `--ds-text-primary`, which is now near-white, so it has to be pinned back
+ or every scroll channel reads as a bright bar.
+ =========================================================================== */
+:root[data-theme="dark"] ::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+:root[data-theme="dark"] ::-webkit-scrollbar-thumb {
+ background: rgba(143, 163, 180, 0.22);
+ border: 2px solid transparent;
+ background-clip: padding-box;
+ border-radius: 9999px;
+}
+:root[data-theme="dark"] :hover::-webkit-scrollbar-thumb,
+:root[data-theme="dark"] :focus-within::-webkit-scrollbar-thumb,
+:root[data-theme="dark"] [data-scrolling]::-webkit-scrollbar-thumb {
+ background: rgba(143, 163, 180, 0.4);
+ background-clip: padding-box;
+}
+:root[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
+ background: rgba(143, 163, 180, 0.55);
+ background-clip: padding-box;
+}
+:root[data-theme="dark"] ::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+:root[data-theme="dark"] ::selection {
+ background: rgba(92, 208, 184, 0.3);
+ color: #F5FBF9;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root[data-theme="dark"] body::after {
+ display: none;
+ }
+}
diff --git a/tests/obsidian-theme.test.mjs b/tests/obsidian-theme.test.mjs
new file mode 100644
index 0000000..79dc4c4
--- /dev/null
+++ b/tests/obsidian-theme.test.mjs
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = join(fileURLToPath(new URL("..", import.meta.url)));
+const pluginRoot = join(root, "plugins", "pi.obsidian-theme");
+const require = createRequire(import.meta.url);
+const manifest = JSON.parse(readFileSync(join(pluginRoot, "manifest.json"), "utf8"));
+const themeCss = readFileSync(join(pluginRoot, "themes", "obsidian.css"), "utf8");
+const panelHtml = readFileSync(join(pluginRoot, "renderer", "index.html"), "utf8");
+
+test("Obsidian theme declares the exact release identity and capabilities", () => {
+ assert.equal(manifest.schemaVersion, 1);
+ assert.equal(manifest.id, "pi.obsidian-theme");
+ assert.equal(manifest.version, "3.0.0");
+ assert.equal(manifest.author, "ily55421");
+ assert.deepEqual(manifest.categories, ["theme", "community"]);
+ assert.equal(manifest.main, "main.js");
+ assert.equal(manifest.ui.panel, "renderer/index.html");
+ assert.deepEqual(manifest.ui.title, {
+ en: "Obsidian Theme",
+ "zh-CN": "黑曜石主题",
+ });
+ // Styling only: no filesystem, network, clipboard, shell or agent access.
+ assert.deepEqual(manifest.permissions, ["ui.panel", "ui.theme"]);
+ assert.deepEqual(manifest.contributes.themes, [
+ {
+ id: "obsidian",
+ label: "Obsidian Dark",
+ base: "dark",
+ path: "themes/obsidian.css",
+ },
+ ]);
+ assert.ok(manifest.i18n?.en?.safetyNotes);
+ assert.ok(manifest.i18n?.["zh-CN"]?.safetyNotes);
+});
+
+test("the theme ships no network, font or external references", () => {
+ // The host sanitizes theme CSS; a self-contained sheet keeps it that way.
+ assert.doesNotMatch(themeCss, /@import/);
+ assert.doesNotMatch(themeCss, /@font-face/);
+ assert.doesNotMatch(themeCss, /https?:\/\//);
+ assert.doesNotMatch(themeCss, /expression\s*\(|javascript:/i);
+
+ // Every url() must be an inline data: payload. Parsing the references beats a
+ // lookahead regex here: `url("data:...` backtracks into a false positive.
+ const references = [...themeCss.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/g)].map(
+ (match) => match[2],
+ );
+ assert.ok(references.length > 0, "the sheet is expected to carry its noise tile");
+ for (const reference of references) {
+ assert.ok(
+ reference.startsWith("data:"),
+ `theme CSS must not reference an external URL: ${reference}`,
+ );
+ }
+});
+
+test("the panel documents the host-owned 46px chrome contract", () => {
+ assert.match(panelHtml, //);
+ assert.match(panelHtml, /three-button[\s\S]*window-control capsule/);
+ assert.match(panelHtml, /var\(--pi-plugin-titlebar-height, 46px\)/);
+});
+
+test("the panel carries no external script or inline handler", () => {
+ assert.doesNotMatch(panelHtml, /