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/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 0000000..3fe9e6a Binary files /dev/null and b/packages/pi.obsidian-theme-3.0.0.piplug differ 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", () => {