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 + + + + + +
+ +
+

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

+
    +
  1. Open Settings → Theme and search Obsidian.
  2. +
  3. Pick Obsidian Dark. The whole app repaints instantly.
  4. +
  5. To revert, choose Light, Dark or System — the plugin can stay installed with no effect.
  6. +
+
+ +
+

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, /]+src\s*=\s*["'](?:https?:|data:)/i); + assert.doesNotMatch(panelHtml, /\son[a-z]+\s*=\s*["']/i); +}); + +test("main.js registers exactly one command and unregisters it", async () => { + const registered = []; + const unregistered = []; + global.pi = { + commands: { + register: async (command) => registered.push(command), + unregister: async (id) => unregistered.push(id), + }, + ui: { openPanel: async () => {} }, + }; + + const main = require(join(pluginRoot, "main.js")); + await main.onLoad(); + assert.equal(registered.length, 1); + assert.equal(registered[0].id, "obsidian-theme.open"); + assert.equal(typeof registered[0].run, "function"); + + await main.onUnload(); + assert.deepEqual(unregistered, ["obsidian-theme.open"]); + delete global.pi; +}); diff --git a/tests/panel-chrome.test.mjs b/tests/panel-chrome.test.mjs index 95380e3..8a1b5a3 100644 --- a/tests/panel-chrome.test.mjs +++ b/tests/panel-chrome.test.mjs @@ -45,7 +45,7 @@ test("every panel plugin documents the host-owned 46px chrome contract", () => { ); } - assert.equal(panelPlugins.length, 19, "the official marketplace should cover all panel plugins"); + assert.equal(panelPlugins.length, 20, "the official marketplace should cover all panel plugins"); }); test("super domain keeps its v3 surface full-bleed and interactive", () => { From 3002888fa0de375f7cdf4799b10555eed69ac785 Mon Sep 17 00:00:00 2001 From: link <511373265@qq.com> Date: Sun, 20 Sep 2026 09:33:11 +0800 Subject: [PATCH 2/2] chore: pack pi.obsidian-theme 3.0.0 and rebuild catalog --- catalog.json | 46 +++++++++++++++++++++++- packages/pi.obsidian-theme-3.0.0.piplug | Bin 0 -> 61705 bytes 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 packages/pi.obsidian-theme-3.0.0.piplug diff --git a/catalog.json b/catalog.json index 035417e..b6a46c8 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-19T06:13:43Z", + "updatedAt": "2026-09-20T01:32:20Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -805,6 +805,50 @@ } ] }, + { + "id": "pi.obsidian-theme", + "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 \u2014 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 \u2014 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": "\u9ed1\u66dc\u77f3\u4e3b\u9898", + "description": "\u6df1\u84dd\u9752\u5168\u5c40\u4e3b\u9898\uff0c\u6309\u5b9e\u6d4b\u53c2\u8003\u56fe\u6821\u51c6\uff1a\u8868\u9762\u7531 #0F1B26 \u5206\u5c42\u62ac\u81f3 #223648\uff0c\u5c42\u7ea7\u7528 1px #2A4054 \u53d1\u4e1d\u8fb9\u6846\u800c\u975e\u53d1\u5149\u627f\u62c5\uff0c\u9009\u4e2d\u6001\u4e3a\u5b9e\u5fc3 #1B4F53\uff0c\u56db\u6863\u6587\u5b57\u5c42\u7ea7\u628a\u6b63\u6587\u5bf9\u6bd4\u5ea6\u4ece 17:1 \u964d\u5230 11:1\u3002\u9664\u6837\u5f0f\u5916\u4e0d\u63d0\u4f9b\u4efb\u4f55\u529f\u80fd\u3002", + "safetyNotes": "\u7eaf\u6837\u5f0f\u63d2\u4ef6\u3002\u4ec5\u8d21\u732e\u4e00\u4e2a\u5168\u5c40\u4e3b\u9898\uff08\u53ea\u6709 CSS\uff09\u548c\u4e00\u4e2a\u8bf4\u660e\u9762\u677f\u3002\u65e0\u7f51\u7edc\u3001\u6587\u4ef6\u3001\u526a\u8d34\u677f\u3001\u547d\u4ee4\u6267\u884c\u6216\u667a\u80fd\u4f53\u6743\u9650\u3002" + } + }, + "author": "ily55421", + "categories": [ + "theme", + "community" + ], + "verified": true, + "downloads": 0, + "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/pi.obsidian-theme", + "repository": "https://github.com/vastsa/pi-desktop-plugins", + "readmeMarkdown": "# Obsidian Theme / \u9ed1\u66dc\u77f3\u4e3b\u9898\n\nA 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 \u2014 no features.\n\nPI-Desktop \u6df1\u84dd\u7070\u5168\u5c40\u4e3b\u9898\uff0c\u4e3a\u957f\u65f6\u95f4\u9605\u8bfb\u8c03\u6821\uff1a\u5206\u5c42\u6d77\u62d4\u800c\u975e\u4e00\u7247\u6b7b\u9ed1\u3001\u7528 1px \u53d1\u4e1d\u8fb9\u6846\u800c\u975e\u53d1\u5149\u5efa\u7acb\u5c42\u7ea7\u3001\u4f4e\u9971\u548c\u9752\u7eff\u5f3a\u8c03\u8272\u3001\u56db\u6863\u6587\u5b57\u5c42\u7ea7\u3002\u7eaf\u6837\u5f0f\uff0c\u65e0\u989d\u5916\u529f\u80fd\u3002\n\n## Palette / \u8272\u677f\n\n| Role / \u7528\u9014 | Value | | Role / \u7528\u9014 | Value |\n|---|---|---|---|---|\n| App base / \u5e94\u7528\u5e95\u8272 | `#0F1B26` | | Title text / \u6807\u9898 | `#F2F7FA` |\n| Rails / \u4fa7\u680f | `#12202C` | | Body prose / \u6b63\u6587 | `#C4D2DD` |\n| Reading column / \u9605\u8bfb\u533a | `#0E1A25` | | Secondary / \u6b21\u7ea7 | `#8FA3B4` |\n| Card / composer / \u5361\u7247 | `#182835` | | Hints / \u5f31\u63d0\u793a | `#6A7E8F` |\n| Raised / \u60ac\u6d6e | `#1E3040` | | Accent / \u5f3a\u8c03 | `#5CD0B8` |\n| Hover | `#223648` | | Border / \u8fb9\u6846 | `#2A4054` |\n| Field / \u8f93\u5165\u6846 | `#1B2C3B` | | Inset / \u51f9\u9677 | `#091219` |\n| Selected row / \u9009\u4e2d\u6001 | `#1B4F53`\u2192`#15414A` | | Info / Warning / Danger | `#7FB4F0` `#E8C878` `#E78282` |\n\nNo pure black, no pure white. / \u4e0d\u4f7f\u7528\u7eaf\u9ed1\uff0c\u4e0d\u4f7f\u7528\u7eaf\u767d\u3002\n\n## What it does / \u529f\u80fd\n\n- One global theme, selectable in **Settings \u2192 Theme \u2192 search \"Obsidian\" / \u8bbe\u7f6e \u2192 \u4e3b\u9898 \u2192 \u641c\u7d22 \"Obsidian\"**.\n- **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.\n- **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.\n- **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 \u2014 still AAA, far less glaring \u2014 while secondary text went slightly up.\n- **Accent used as a material, sparingly.** `#5CD0B8` on selection, the send button, `.btn-primary`, focus rings and the active session rail; nothing else.\n- **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.\n- Code cards and blockquotes sit on `#16242F`; blockquotes carry a `#5CD0B8` rule; tables get a real row seam; meta and path lines are monospace.\n- \u6d77\u62d4\u9636\u68af\uff1a\u4ece `#091219`\uff08\u51f9\u9677\uff09\u5230 `#223648`\uff08hover\uff09\u5171\u516b\u6863\u8868\u9762\u8272\uff0c\u4e09\u680f\u4e0e\u5176\u5185\u90e8\u5361\u7247\u4e0d\u518d\u7cca\u6210\u4e00\u7247\u3002\n- \u5c42\u7ea7\u9760\u8fb9\u6846\u800c\u975e\u53d1\u5149\uff1a\u6240\u6709\u9762\u677f\u3001\u5361\u7247\u3001\u8f93\u5165\u6846\u3001\u4ee3\u7801\u5757\u90fd\u5e26 1px \u5b9e\u8272\u53d1\u4e1d\u8fb9\uff08`#2A4054` / `#1C2B3A`\uff09\uff0c\u53d1\u5149\u9634\u5f71\u5168\u90e8\u79fb\u9664\u3002\n- \u56db\u6863\u6587\u5b57\uff1a\u6807\u9898 / \u6b63\u6587 / \u6b21\u7ea7 / \u5f31\u63d0\u793a\u3002\u6b63\u6587\u5bf9\u6bd4\u5ea6\u7531 17:1 \u964d\u5230 11.3:1\uff08\u4ecd\u662f AAA\uff09\u4e0d\u518d\u523a\u773c\uff0c\u6b21\u7ea7\u6587\u5b57\u7565\u6709\u63d0\u5347\u3002\n- \u5f3a\u8c03\u8272\u514b\u5236\u4f7f\u7528\uff1a\u4ec5\u9009\u4e2d\u6001\u3001\u53d1\u9001\u6309\u94ae\u3001\u4e3b\u6309\u94ae\u3001\u805a\u7126\u63cf\u8fb9\u4e0e\u5f53\u524d\u4f1a\u8bdd\u6761\u76ee\u3002\n- \u8f93\u5165\u6846\u6bd4\u753b\u5e03\u66f4\u4eae\u5e76\u5e26\u53d1\u4e1d\u8fb9\uff0c\u56e0\u6b64\u8f93\u5165\u533a\u4e0d\u4f1a\u518d\"\u6d88\u5931\"\uff1b\u9009\u4e2d\u884c\u7528\u5b9e\u5fc3 `#1B4F53` \u800c\u975e\u534a\u900f\u660e\u7f69\u8272\u3002\n- \u4ee3\u7801\u5361\u7247\u4e0e\u5f15\u7528\u5757\u5e95\u8272 `#16242F`\uff0c\u5f15\u7528\u5757\u914d `#5CD0B8` \u7ad6\u6761\uff1b\u8868\u683c\u8865\u4e0a\u884c\u5206\u9694\u7ebf\uff1b\u5143\u4fe1\u606f\u4e0e\u8def\u5f84\u884c\u6539\u7528\u7b49\u5bbd\u5b57\u4f53\u3002\n\n## Reverting / \u6062\u590d\n\nPick Light / Dark / System in the theme picker \u2014 the plugin can stay installed without effect. Or uninstall the plugin.\n\n\u5728\u4e3b\u9898\u9009\u62e9\u5668\u4e2d\u5207\u56de\u6d45\u8272 / \u6df1\u8272 / \u7cfb\u7edf\u5373\u53ef\uff1b\u63d2\u4ef6\u4fdd\u7559\u5b89\u88c5\u4e5f\u65e0\u526f\u4f5c\u7528\uff0c\u6216\u76f4\u63a5\u5378\u8f7d\u3002\n\n## Permissions / \u6743\u9650\n\n| Permission | Why / \u539f\u56e0 |\n|---|---|\n| `ui.panel` | Bilingual info panel: palette reference and elevation preview / \u53cc\u8bed\u8bf4\u660e\u9762\u677f\uff1a\u8272\u677f\u53c2\u8003\u4e0e\u6d77\u62d4\u9884\u89c8 |\n| `ui.theme` | Contribute the global theme / \u8d21\u732e\u5168\u5c40\u4e3b\u9898 |\n\n## Safety / \u5b89\u5168\n\nCSS is sanitized by the host on load (256 KiB cap, no `@import`, no markup, no scripts, no external URLs \u2014 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.\n\nCSS \u7531\u5bbf\u4e3b\u5728\u52a0\u8f7d\u65f6\u51c0\u5316\uff08\u4e0a\u9650 256 KiB\uff0c\u7981\u6b62 `@import`\u3001\u6807\u8bb0\u3001\u811a\u672c\u4e0e\u5916\u90e8 URL\uff0c\u4ec5\u5141\u8bb8 `data:` URL \u4e0e\u6e05\u5355\u58f0\u660e\u7684\u8d44\u6e90\uff09\u3002\u672c\u6837\u5f0f\u8868\u53ea\u542b\u4e00\u4e2a\u5f15\u7528\uff1a\u4e00\u4e2a base64 \u7684 `data:` SVG \u566a\u70b9\u8d34\u56fe\u3002\u63d2\u4ef6\u4e0d\u53d1\u8d77\u7f51\u7edc\u8bf7\u6c42\u3001\u4e0d\u8bfb\u53d6\u6587\u4ef6\u3001\u4e0d\u5b58\u50a8\u6570\u636e\u3002\n\n## Files / \u6587\u4ef6\n\n| File | Purpose / \u4f5c\u7528 |\n|---|---|\n| `manifest.json` | Identity, permissions, the `contributes.themes` entry / \u6807\u8bc6\u3001\u6743\u9650\u3001\u4e3b\u9898\u8d21\u732e\u58f0\u660e |\n| `main.js` | Registers the one \"open panel\" command / \u6ce8\u518c\u552f\u4e00\u7684\u6253\u5f00\u9762\u677f\u547d\u4ee4 |\n| `themes/obsidian.css` | The theme itself / \u4e3b\u9898\u672c\u4f53 |\n| `renderer/index.html` | Info panel / \u8bf4\u660e\u9762\u677f |\n| `renderer/panel.js` | Panel copy + locale switching / \u9762\u677f\u6587\u6848\u4e0e\u8bed\u8a00\u5207\u6362 |\n| `renderer/appearance-boot.js` | Shared appearance adapter, pre-paint / \u5171\u4eab\u5916\u89c2\u9002\u914d\u5668\uff08\u9996\u5e27\uff09 |\n| `renderer/appearance.js` | Shared appearance adapter, live updates / \u5171\u4eab\u5916\u89c2\u9002\u914d\u5668\uff08\u5b9e\u65f6\u66f4\u65b0\uff09 |\n| `renderer/capsule-retint.js` | Retints the host window-control capsule / \u91cd\u67d3\u5bbf\u4e3b\u7a97\u53e3\u63a7\u5236\u80f6\u56ca |\n\n## Notes on the 3.0.0 revision / 3.0.0 \u6539\u7248\u8bf4\u660e\n\nThe 2.0.0 build was measured against a reference screenshot, and the finding was\nnot about lightness at all \u2014 it was **hue**. Blue-minus-red on the 2.0.0 canvas\nwas 7, i.e. grey; the reference sits at 21\u201324. Every surface now carries a real\nblue lead (measured 23 across the whole rendered frame).\n\nA band scan also **disproved the gradient theory**: the reference panels are\nessentially flat (drift \u22120.11\u2026\u22120.23 pts), so hierarchy is carried instead by\nreal 1px borders and by a confident selected fill. 2.0.0 used a 13% accent wash\nfor selection, which measured as nothing; the solid `#1B4F53` gradient now lifts\n+3.6 pts over a plain row, matching the reference's own +2.1.\n\nFluorescent mint was replaced by a lower-chroma teal and confined to selection,\nprimary actions and focus. Bloom shadows gave way to a 3\u20135% top light, grain was\ncut to 0.035, radius was pulled onto one 8/10/12 ladder, prose line-height was\nraised to 1.72, and the scrollbars \u2014 which the host builds out of\n`--ds-text-primary`, now near-white \u2014 were pinned back so they stop reading as\nbright bars.\n\n## Notes on the 2.0.0 revision / 2.0.0 \u6539\u7248\u8bf4\u660e\n\nThe 1.0.0 build was measured against a real screenshot and two of its ideas were wrong:\n\n1. **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.\n2. **`soft-light` grain is a no-op on near-black.** Its effect scales with `backdrop \u00d7 (1 \u2212 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.\n\nRecessing the composer into a darker well also backfired \u2014 it simply disappeared. Fields are lighter than the canvas again.\n\n1.0.0 \u7248\u672c\u7ecf\u771f\u5b9e\u622a\u56fe\u6d4b\u91cf\u540e\uff0c\u53d1\u73b0\u4e24\u5904\u5224\u65ad\u5931\u8bef\uff1a\n\n1. **\u534a\u900f\u660e\u767d\u8272\u8272\u5757\u65e0\u6cd5\u5206\u9694\u6df1\u8272\u8868\u9762**\uff1a\u8fd1\u9ed1\u753b\u5e03\u4e0a\u53e0 3.5% \u767d\uff0c\u5361\u7247\u4e0e\u9875\u9762\u5728\u89c6\u89c9\u4e0a\u5b8c\u5168\u76f8\u540c\u3002\u6539\u7528\u4e0d\u900f\u660e\u5206\u6863\u8272\u503c + \u5b9e\u8272 1px \u8fb9\u6846\u3002\n2. **`soft-light` \u566a\u70b9\u5728\u8fd1\u9ed1\u80cc\u666f\u4e0a\u7b49\u4e8e\u6ca1\u6709**\uff1a\u5176\u5f3a\u5ea6\u4e0e `\u80cc\u666f \u00d7 (1 \u2212 \u80cc\u666f)` \u6210\u6b63\u6bd4\uff0c\u5728 `#0a0e0c` \u5904\u4ec5\u7ea6 0.04\u3002\u73b0\u6539\u4e3a 0.05 \u4e0d\u900f\u660e\u5ea6\u7684 `overlay` \u8584\u819c\uff0c\u4fdd\u7559\u5b83\u662f\u56e0\u4e3a\u4ecd\u80fd\u6253\u7834\u5927\u9762\u79ef\u7eaf\u8272\u3002\n\n\u6b64\u5916\u628a\u8f93\u5165\u6846\u505a\u6210\u6bd4\u753b\u5e03\u66f4\u6697\u7684\"\u51f9\u4e95\"\u4e5f\u9002\u5f97\u5176\u53cd\u2014\u2014\u5b83\u76f4\u63a5\u770b\u4e0d\u89c1\u4e86\uff0c\u73b0\u5df2\u6539\u56de\u6bd4\u753b\u5e03\u66f4\u4eae\u3002\n", + "safetyNotes": "Pure styling. The plugin contributes one global theme (CSS only) and a small info panel. No network, file, clipboard, shell, or agent access.", + "versions": [ + { + "version": "3.0.0", + "publishedAt": "2026-09-20T01:32:20Z", + "changelog": "Release 3.0.0: retuned against a measured reference screenshot. The decisive finding was hue, not lightness \u2014 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.", + "minPiDesktop": ">=0.15.0", + "shasum": "e03fd14aa9b11c984a6d5876477a3898f5ab30123357891962405af5cb7fd32e", + "url": "packages/pi.obsidian-theme-3.0.0.piplug", + "sizeBytes": 61705, + "permissions": [ + "ui.panel", + "ui.theme" + ], + "fs": {} + } + ] + }, { "id": "pi.parchment", "name": "Parchment Theme", diff --git a/packages/pi.obsidian-theme-3.0.0.piplug b/packages/pi.obsidian-theme-3.0.0.piplug new file mode 100644 index 0000000000000000000000000000000000000000..3fe9e6aade376d13db8a80669bcf1a013d5f3bcc GIT binary patch literal 61705 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