dsh-better-workspace 0.9.6 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
4
 
5
+ 简体中文 | [English](README_EN.md)
6
+
5
7
  > 给 [DeepSeek Harness (DSH)](https://www.npmjs.com/package/@deepseek-ai/dsh) 的侧边栏「工作区」列表装上一套**文件夹系统**——工作区还是那个工作区(一个目录),但名字里的 `/` 就是层级,让「网页前端」「网页后端」这类工作区可以归到同一个 `web` 分组下。
6
8
 
7
9
  ```
@@ -35,12 +37,12 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
35
37
 
36
38
  **重启后标题不回退**:DSH 重启后的冷列表只为命中投影缓存的会话携带标题——分叉出的会话与从未写过检查点的会话会暂时回退成「工作区名」显示,真实的标题其实还在会话日志里。本插件把每个会话**最近一次的真实标题**持久化在浏览器里(带防抖的本地缓存,变更合并整写一次),回退窗口内直接用记忆标题渲染,`/` 分级分组照常成立;打开会话后官方数据到位即自动纠正。
37
39
 
38
- **跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
40
+ **跨端手动同步(Web ↔ 桌面客户端)**:外观自定义、默认外观、显式分组与开关默认保存在各设备自己的浏览器里;设置卡片新增「跨端同步」区——Web 上点「从客户端获取」、客户端上点「从 Web 获取」,可选**覆盖本设备**或**合并两端**(并集保留各自独有项),另一端的数据经宿主设置存储(~/.dsh/settings.yaml,两端共享)送达;旁边有「发送本设备数据」按钮,旧版本的历史数据首次同步推一次即可,之后每次修改都会自动写入宿主、对端随手可取。折叠状态仍保持每端独立。
39
41
 
40
42
  <table>
41
43
  <tr>
42
44
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="外观自定义弹窗(实时预览)"/></td>
43
- <td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)、字体粗细、字体阴影开关、图标(与 dsh 图标库同族的 69 个,工作区与工作区分组专属),底部带实时预览。清除自定义一键复原。</td>
45
+ <td valign="top"><b>外观自定义</b><br/>右键任意一层(工作区分组 / 工作区 / 会话 / 会话分组)→「自定义外观」:颜色(预设色板 + 取色器 + RGB)、发光强度(0..14 拖动条,只作用于文字)、字体粗细、字体阴影开关、**字体描边**(开关 + 0.5–4px 粗细,**默认开启**;描边颜色默认**灰**,可改黑 / 白 / 任意取色,或选「自动」按字体颜色取反差色——浅色字配黑边、深色字配白边,并跟随主题与背景插件的界面明暗)、图标(与 dsh 图标库同族的 69 个,工作区与工作区分组专属),底部带实时预览。清除自定义一键复原。</td>
44
46
  </tr>
45
47
  <tr>
46
48
  <td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="外观自定义效果"/></td>
@@ -58,7 +60,7 @@ web/ ← 虚拟分组(不是真实目录,纯命名层级)
58
60
  <table>
59
61
  <tr>
60
62
  <td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="插件设置卡片"/></td>
61
- <td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>状态呼吸灯</b>开关(被折叠藏起的状态灯沿层级向外冒泡呼吸发光,默认开);展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
63
+ <td valign="top"><b>设置卡片</b><br/>设置 → 插件 → 插件配置 里新增「更好的工作区」卡片(官方折叠卡片样式),提供<b>单链分组合并</b>开关(如 测试层1 下只有 AI交易 一个工作区时,合并显示为一行 测试层1/AI交易)与<b>状态呼吸灯</b>开关(被折叠藏起的状态灯沿层级向外冒泡呼吸发光,默认开);另有<b>默认外观</b>区——颜色 / 发光 / 字体粗细 / 字体阴影 / 字体描边开关 / 描边粗细一次配好,没有单独自定义过的行全部继承(只有描边默认开,其余同「无自定义」),并随宿主设置存储跨端同步。展开状态与外观自定义经 dsh 客户端 store 持久化在当前浏览器。</td>
62
64
  </tr>
63
65
  </table>
64
66
 
package/README_EN.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
4
 
5
+ [简体中文](README.md) | English
6
+
5
7
  > A **folder system** for the DeepSeek Harness (DSH) sidebar workspace list — a workspace is still one directory, but every `/` in its name becomes hierarchy, so `web/frontend` and `web/backend` group under one virtual `web` folder.
6
8
 
7
9
  ```
@@ -33,12 +35,12 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
33
35
 
34
36
  **No title regression after a restart**: the cold list right after a DSH restart only carries titles for sessions that hit the persisted projection cache — fork-born and never-checkpointed sessions temporarily fall back to the workspace *basename*, even though the real title still lives in the session log. This plugin remembers each session's **last real wire title** in the browser (a debounced local cache: real changes coalesce into one write) and renders from memory during that window, so `/` grouping keeps working; the moment a session opens, the official data takes over and self-heals.
35
37
 
36
- **Manual cross-device sync (web ↔ desktop app)**: appearance, explicit folders, and toggles live in each device's own browser by default. The settings card adds a "Cross-device sync" section — "Pull from desktop app" on the web / "Pull from web" in the desktop app, with an **overwrite-this-device** or **merge-both-sides** mode (the union keeps each side's unique entries); the other surface's data travels through the host settings store (~/.dsh/settings.yaml, shared by both surfaces). A "Send this device's data" button next to it covers history created before this version — push once, and afterwards every edit is written to the host automatically for the other surface to pull. Expansion state stays per-device.
38
+ **Manual cross-device sync (web ↔ desktop app)**: appearance, the default appearance, explicit folders, and toggles live in each device's own browser by default. The settings card adds a "Cross-device sync" section — "Pull from desktop app" on the web / "Pull from web" in the desktop app, with an **overwrite-this-device** or **merge-both-sides** mode (the union keeps each side's unique entries); the other surface's data travels through the host settings store (~/.dsh/settings.yaml, shared by both surfaces). A "Send this device's data" button next to it covers history created before this version — push once, and afterwards every edit is written to the host automatically for the other surface to pull. Expansion state stays per-device.
37
39
 
38
40
  <table>
39
41
  <tr>
40
42
  <td align="center" width="58%"><img src="docs/screenshots/3-appearance-dialog.png" alt="Appearance dialog with live preview"/></td>
41
- <td valign="top"><b>Appearance customization</b><br/>Right-click any row (workspace folder / workspace / session / session group) → Customize: color (swatches + picker + RGB), glow strength (0–14 slider, text only), font weight, font shadow, an icon grid (69 icons from the dsh primitives family, workspace &amp; folder rows only), with a live preview at the bottom. Reset returns to default in one click.</td>
43
+ <td valign="top"><b>Appearance customization</b><br/>Right-click any row (workspace folder / workspace / session / session group) → Customize: color (swatches + picker + RGB), glow strength (0–14 slider, text only), font weight, font shadow, a <b>text outline</b> (toggle + 0.5–4px width, <b>on by default</b>; its color is gray by default, with black / white / any-color picker and an Auto mode that derives the contrasting pole from the row's font color — light text gets a black rim, dark text a white one — following the theme or a background plugin's light/dark switch), an icon grid (69 icons from the dsh primitives family, workspace &amp; folder rows only), with a live preview at the bottom. Reset returns to default in one click.</td>
42
44
  </tr>
43
45
  <tr>
44
46
  <td align="center" width="58%"><img src="docs/screenshots/2-customized-tree.png" alt="Custom appearance result"/></td>
@@ -56,7 +58,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
56
58
  <table>
57
59
  <tr>
58
60
  <td align="center" width="58%"><img src="docs/screenshots/4-settings-card.png" alt="Settings card"/></td>
59
- <td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default); expansion state and styling persist via the dsh client store in the browser.</td>
61
+ <td valign="top"><b>Settings card</b><br/>Settings → Plugins → Configuration adds a “Better Workspaces” card (official accordion style) with a <b>single-child-chain merge</b> toggle (e.g. level1 containing only AI trade renders as one row level1/AI trade) and a <b>status breathing light</b> toggle (status dots hidden by collapse bubble outward as breathing glows; on by default); a <b>default appearance</b> section configures color / glow / weight / shadow / outline toggle / outline width once for every row that was never customized (only the outline defaults to on, the rest match “no customization”) and syncs across surfaces through the host settings store. Expansion state and styling persist via the dsh client store in the browser.</td>
60
62
  </tr>
61
63
  </table>
62
64
 
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区层级树:名称中的 / 即虚拟分组(web/前端 · web/后端),添加工作区的目录流附所属分组弹窗,重命名即时重排层级,可新建空分组。Hierarchy tree for the DSH sidebar workspace list.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
4
4
  "description": "DSH web plugin: a hierarchical workspace tree for the sidebar. Workspace titles containing \"/\" group into virtual folders (web/前端 · web/后端); the add-workspace directory flow gains a parent-group popup; renames re-derive the tree; a new-folder button creates empty levels.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -69,6 +69,9 @@ window.__ModuleLoader__.load({
69
69
  'settings.compactChains.hint': '单层链合并为一行,出现多个子级时自动展开为树状;拖拽工作区期间单链临时展开回文件夹树,可放入任意一级;展开状态与自定义外观保存在当前浏览器。',
70
70
  'settings.statusPulse': '状态呼吸灯',
71
71
  'settings.statusPulse.hint': '被折叠藏起的状态灯(完成绿 / 运行蓝 / 待交互琥珀)沿层级向外冒泡:工作区与分组行以图标呼吸发光(颜色随状态,自定义过发光的标题一起呼吸),会话分组行显示呼吸状态灯;默认开启,可在此关闭。',
72
+ 'settings.appearance': '默认外观',
73
+ 'settings.appearance.hint': '没有单独自定义过的行使用这套外观;字体描边默认开启——背景画面下不描边文字常常看不清。字体颜色留空即跟随主题。',
74
+ 'settings.appearance.reset': '恢复默认外观',
72
75
  'custom.title': '自定义外观',
73
76
  'custom.color': '颜色',
74
77
  'custom.glow': '发光',
@@ -80,6 +83,11 @@ window.__ModuleLoader__.load({
80
83
  'custom.weight.semibold': '半粗',
81
84
  'custom.weight.bold': '粗',
82
85
  'custom.shadow': '字体阴影',
86
+ 'custom.stroke': '字体描边',
87
+ 'custom.stroke.hint': '描边颜色默认灰色,可改黑 / 白 / 任意取色,或选「自动」按字体颜色取反差色(浅色字配黑边、深色字配白边);自动模式跟随主题明暗与背景插件的界面明暗。',
88
+ 'custom.strokeWidth': '描边粗细',
89
+ 'custom.strokeColor': '描边颜色',
90
+ 'custom.strokeColor.auto': '自动',
83
91
  'custom.weak': '弱',
84
92
  'custom.medium': '中',
85
93
  'custom.strong': '强',
@@ -169,6 +177,9 @@ window.__ModuleLoader__.load({
169
177
  'settings.compactChains.hint': 'Single-child chains merge into one row; levels with multiple children expand as a tree. Chains re-expand into folder rows while you drag a workspace, so it can drop into any level. State and custom styling persist in this browser.',
170
178
  'settings.statusPulse': 'Status breathing light',
171
179
  'settings.statusPulse.hint': 'Status dots hidden by collapse (done green / running blue / pending amber) bubble outward: workspace and folder rows breathe on their icon in the status color (custom-glow labels breathe along), session-group rows show a breathing dot; on by default, turn it off here.',
180
+ 'settings.appearance': 'Default appearance',
181
+ 'settings.appearance.hint': 'Rows that were never customized use this appearance; the outline is on by default — text without it is often unreadable over a background image. Leave the color empty to follow the theme.',
182
+ 'settings.appearance.reset': 'Reset to default',
172
183
  'custom.title': 'Customize',
173
184
  'custom.color': 'Color',
174
185
  'custom.glow': 'Glow',
@@ -180,6 +191,11 @@ window.__ModuleLoader__.load({
180
191
  'custom.weight.semibold': 'Semi-bold',
181
192
  'custom.weight.bold': 'Bold',
182
193
  'custom.shadow': 'Font shadow',
194
+ 'custom.stroke': 'Font outline',
195
+ 'custom.stroke.hint': 'The outline is gray by default — pick black, white, any color, or Auto to derive the contrasting pole from the font color (light text gets a black rim, dark text a white one); Auto follows the theme and a background plugin\'s light/dark switch.',
196
+ 'custom.strokeWidth': 'Outline width',
197
+ 'custom.strokeColor': 'Outline color',
198
+ 'custom.strokeColor.auto': 'Auto',
183
199
  'custom.weak': 'Subtle',
184
200
  'custom.medium': 'Medium',
185
201
  'custom.strong': 'Strong',
@@ -586,9 +602,13 @@ window.__ModuleLoader__.load({
586
602
  '.bw-row-icon{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a)}',
587
603
  '.bw-chevron{flex:none;display:grid;place-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);transition:transform .15s ease}',
588
604
  '.bw-chevron-open{transform:rotate(90deg)}',
589
- '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
605
+ '.bw-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:1px;margin:-1px}',
590
606
  '.bw-row-count{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
591
607
  '.bw-row-time{flex:none;font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a)}',
608
+ // The outline is inherited from the row: the 11px meta column (session
609
+ // count / relative time) opts out — stroked meta text outshines the title
610
+ // it is supposed to support.
611
+ '.bw-row-count,.bw-row-time{-webkit-text-stroke-width:0}',
592
612
  '.bw-schedule-badge{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary,#9a9a9a);margin:0 6px}',
593
613
  '.bw-row-actions{flex:none;display:none;align-items:center;gap:2px}',
594
614
  '.bw-row:hover .bw-row-actions{display:flex}',
@@ -600,6 +620,7 @@ window.__ModuleLoader__.load({
600
620
  '.bw-session-row:hover{color:var(--dsw-alias-label-primary,#e6e6e6)}',
601
621
  '.bw-empty{padding:28px 12px;text-align:center;font-size:12px;color:var(--dsw-alias-label-dimmed,#7a7a7a)}',
602
622
  '.bw-swatch{width:20px;height:20px;border-radius:6px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));cursor:pointer;flex:none;background:transparent;padding:0}',
623
+ '.bw-swatch-wide{width:auto;min-width:38px;padding:0 8px;font-size:11px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
603
624
  '.bw-swatch-active{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:1px}',
604
625
  '.bw-color-input{width:36px;height:26px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.25));border-radius:6px;background:transparent;cursor:pointer;padding:0}',
605
626
  '.bw-seg{display:flex;gap:6px;flex-wrap:wrap}',
@@ -647,6 +668,8 @@ window.__ModuleLoader__.load({
647
668
  '.bw-rail-btn:hover{background:var(--dsw-specific-sidebar-nav-item-hover,var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12)));color:var(--dsw-alias-label-primary,#e6e6e6)}',
648
669
  '.bw-modal-body{display:flex;flex-direction:column;gap:10px;min-width:300px;max-width:380px;box-sizing:border-box}',
649
670
  '.bw-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#b8b8b8)}',
671
+ '.bw-appearance{display:flex;flex-direction:column;gap:10px}',
672
+ '.bw-appearance-box{margin-top:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.22));border-radius:8px;background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.06))}',
650
673
  '.bw-hint{font-size:11px;color:var(--dsw-alias-label-quaternary,#8a8a8a);line-height:1.5;white-space:normal;word-break:break-word}',
651
674
  '.bw-path-echo{font-size:11px;color:var(--dsw-alias-label-tertiary,#9a9a9a);word-break:break-all;max-width:380px}',
652
675
  '.bw-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
@@ -699,6 +722,7 @@ window.__ModuleLoader__.load({
699
722
  if (!d.prefs) d.prefs = {}
700
723
  if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
701
724
  if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
725
+ if (host.appearance && typeof host.appearance === 'object') d.prefs.appearance = host.appearance
702
726
  },
703
727
  // Merge mode: union of both sides, the PULLED (host) copy wins any
704
728
  // per-key conflict; prefs take the pulled booleans when present.
@@ -712,6 +736,7 @@ window.__ModuleLoader__.load({
712
736
  if (!d.prefs) d.prefs = {}
713
737
  if (host.compactChains !== undefined) d.prefs.compactChains = host.compactChains !== false
714
738
  if (host.statusPulse !== undefined) d.prefs.statusPulse = host.statusPulse !== false
739
+ if (host.appearance && typeof host.appearance === 'object') d.prefs.appearance = { ...(d.prefs.appearance || {}), ...host.appearance }
715
740
  },
716
741
  setExpanded: (d, key, value) => { if (!d.expanded) d.expanded = {}; d.expanded[key] = value },
717
742
  setSessionsExpanded: (d, key, value) => { if (!d.sessionsExpanded) d.sessionsExpanded = {}; d.sessionsExpanded[key] = value },
@@ -1108,6 +1133,101 @@ window.__ModuleLoader__.load({
1108
1133
  .map(n => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
1109
1134
  .join('')
1110
1135
 
1136
+ /* ------------------- appearance: defaults + outline ------------------- */
1137
+
1138
+ // Text outline. Width and color are DERIVED, never hand-picked: an outline
1139
+ // only helps when it contrasts with the label, and the label color is
1140
+ // whatever the theme — or a background plugin's light/dark switch — hands
1141
+ // us at that moment. contrastStrokeColor() reads the effective font color
1142
+ // and returns the opposite pole; rows without a custom color inherit
1143
+ // --bw-stroke-color, sampled from the live computed style (see the sampler
1144
+ // in BetterBrowser) so a palette flip needs no React render.
1145
+ const STROKE_MAX = 4
1146
+ // The outline color defaults to a plain gray: it reads on light and dark
1147
+ // backgrounds alike without shouting like pure black/white. "auto" derives
1148
+ // the contrasting pole from the font color instead.
1149
+ const DEFAULT_APPEARANCE = { color: '', glow: 0, weight: 0, shadow: false, stroke: true, strokeWidth: 1, strokeColor: '#808080' }
1150
+ const APPEARANCE_FIELDS = ['color', 'glow', 'weight', 'shadow', 'stroke', 'strokeWidth', 'strokeColor']
1151
+ const clampStrokeWidth = (raw) => {
1152
+ const n = Number(raw)
1153
+ if (!isFinite(n) || n <= 0) return DEFAULT_APPEARANCE.strokeWidth
1154
+ return Math.min(STROKE_MAX, Math.max(0.5, Math.round(n * 2) / 2))
1155
+ }
1156
+ // Persisted values arrive from older plugin versions and from the other
1157
+ // surface: every read tolerates missing keys and falls back to the default
1158
+ // (hydration replaces state wholesale — the 0.9.x hard rule).
1159
+ const readAppearance = (raw) => {
1160
+ const src = raw && typeof raw === 'object' ? raw : {}
1161
+ return {
1162
+ color: typeof src.color === 'string' ? src.color : '',
1163
+ glow: Math.max(0, Number(src.glow) || 0),
1164
+ weight: Number(src.weight) || 0,
1165
+ shadow: src.shadow === true,
1166
+ stroke: src.stroke !== false,
1167
+ strokeWidth: src.strokeWidth === undefined ? DEFAULT_APPEARANCE.strokeWidth : clampStrokeWidth(src.strokeWidth),
1168
+ strokeColor: typeof src.strokeColor === 'string' && src.strokeColor !== '' ? src.strokeColor : DEFAULT_APPEARANCE.strokeColor,
1169
+ }
1170
+ }
1171
+ // A row's own entry overrides the default appearance FIELD BY FIELD, so a
1172
+ // row customized before this version (no stroke key) still inherits the new
1173
+ // default outline instead of losing it.
1174
+ const mergeAppearance = (base, entry) => {
1175
+ if (!entry || typeof entry !== 'object') return readAppearance(base)
1176
+ const merged = { ...base }
1177
+ for (const field of APPEARANCE_FIELDS) {
1178
+ if (entry[field] !== undefined) merged[field] = entry[field]
1179
+ }
1180
+ return readAppearance(merged)
1181
+ }
1182
+ const parseCssColor = (css) => {
1183
+ const text = String(css || '')
1184
+ const rgb = /^rgba?\(\s*([0-9.]+)[,\s]+([0-9.]+)[,\s]+([0-9.]+)/.exec(text)
1185
+ if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
1186
+ const srgb = /^color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)/.exec(text)
1187
+ if (srgb) return [Number(srgb[1]) * 255, Number(srgb[2]) * 255, Number(srgb[3]) * 255]
1188
+ return null
1189
+ }
1190
+ const relativeLuminance = (rgb) => {
1191
+ const [r, g, b] = rgb.map((channel) => {
1192
+ const s = Math.max(0, Math.min(255, Number(channel) || 0)) / 255
1193
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
1194
+ })
1195
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
1196
+ }
1197
+ // WCAG contrast against both poles: the outline takes whichever of black /
1198
+ // white stands out more from the label color.
1199
+ const contrastStrokeColor = (rgb) => {
1200
+ if (!rgb) return '#000000'
1201
+ const luminance = relativeLuminance(rgb)
1202
+ return (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) ? '#000000' : '#ffffff'
1203
+ }
1204
+ const STROKE_FALLBACK = 'var(--bw-stroke-color, rgba(0,0,0,.75))'
1205
+ const STROKE_AUTO = 'auto'
1206
+ // A picked color wins; "auto" derives the contrasting pole from the row's
1207
+ // font color — computed in JS when the row carries one, read from the
1208
+ // sampled --bw-stroke-color variable otherwise (so a theme or background
1209
+ // plugin flip needs no render).
1210
+ const strokeColorOf = (appearance) => {
1211
+ const picked = appearance && typeof appearance.strokeColor === 'string' && appearance.strokeColor !== ''
1212
+ ? appearance.strokeColor
1213
+ : DEFAULT_APPEARANCE.strokeColor
1214
+ if (picked === STROKE_AUTO) {
1215
+ const rgb = appearance.color ? colorToRgb(appearance.color) : null
1216
+ return rgb ? contrastStrokeColor(rgb) : STROKE_FALLBACK
1217
+ }
1218
+ return colorToRgb(picked) ? picked : DEFAULT_APPEARANCE.strokeColor
1219
+ }
1220
+ // paint-order keeps the stroke UNDER the fill, so the glyph does not thin
1221
+ // out — the whole point of an outer outline.
1222
+ const strokeStyleOf = (appearance) => {
1223
+ if (!appearance || appearance.stroke === false) return null
1224
+ return {
1225
+ WebkitTextStrokeWidth: clampStrokeWidth(appearance.strokeWidth) + 'px',
1226
+ WebkitTextStrokeColor: strokeColorOf(appearance),
1227
+ paintOrder: 'stroke fill',
1228
+ }
1229
+ }
1230
+
1111
1231
  function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, t }) {
1112
1232
  const total = countWorkspaces(node)
1113
1233
  const iconEl = iconOf(iconMode, expanded)
@@ -1257,6 +1377,10 @@ window.__ModuleLoader__.load({
1257
1377
  /* --------------------- customization dialog ----------------------- */
1258
1378
 
1259
1379
  const SWATCHES = ['', '#5b8def', '#3fb950', '#d29922', '#f85149', '#a371f7', '#39c5cf', '#ec6cb9', '#ff9f45', '#6e7681']
1380
+ // Outline-color presets: gray (the default), black, white, brand blue — the
1381
+ // native picker next to them covers everything else, and "auto" derives the
1382
+ // contrasting pole from the font color.
1383
+ const STROKE_PRESETS = ['#808080', '#000000', '#ffffff', '#5b8def']
1260
1384
  const GLOW_MAX = 14
1261
1385
  const ICON_CHOICES = [
1262
1386
  'solid', 'outline', 'none',
@@ -1281,51 +1405,235 @@ window.__ModuleLoader__.load({
1281
1405
  ]
1282
1406
 
1283
1407
  /**
1284
- * Per-row appearance: color swatches (+ native picker), glow intensity,
1285
- * folder-glyph mode. Committing the defaults clears the entry; Reset
1286
- * removes it entirely.
1408
+ * Appearance controls shared by the per-row dialog and the settings card's
1409
+ * "default appearance" section: color, glow, weight, shadow, the text
1410
+ * outline (on/off + width) and — where the row actually renders one — the
1411
+ * folder glyph. The outline color is never picked by hand (see the
1412
+ * appearance block above): it is derived from the effective font color, so
1413
+ * it keeps working when the palette flips.
1287
1414
  */
1288
- function CustomizeDialog({ open, initial, kind, onChange, onReset, onClose, t }) {
1415
+ function AppearanceControls({ value, onChange, allowIcon, t }) {
1416
+ const appearance = readAppearance(value)
1417
+ const color = appearance.color || ''
1418
+ const glow = appearance.glow
1419
+ const weight = appearance.weight
1420
+ const shadow = appearance.shadow
1421
+ const stroke = appearance.stroke
1422
+ const strokeWidth = appearance.strokeWidth
1423
+ const strokeColor = appearance.strokeColor
1424
+ const iconMode = value && value.icon ? value.icon : 'solid'
1425
+ const patch = (next) => { if (typeof onChange === 'function') onChange(next) }
1426
+ const channels = colorToRgb(color)
1427
+ const setChannel = (index, raw) => {
1428
+ const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
1429
+ const base = channels || [0, 0, 0]
1430
+ const next = base.slice()
1431
+ next[index] = n
1432
+ patch({ color: rgbToHex(next[0], next[1], next[2]) })
1433
+ }
1434
+ const previewShadows = []
1435
+ if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
1436
+ if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
1437
+ // The preview must show the REAL outline: with no custom color the pole
1438
+ // comes from the preview text's own computed color, so it reads correctly
1439
+ // in the current theme (including a background plugin's light/dark flip).
1440
+ const previewRef = React.useRef(null)
1441
+ React.useEffect(() => {
1442
+ const el = previewRef.current
1443
+ if (!el || typeof getComputedStyle !== 'function') return undefined
1444
+ const sample = () => {
1445
+ try {
1446
+ const rgb = parseCssColor(getComputedStyle(el).color)
1447
+ if (rgb) el.style.setProperty('--bw-stroke-color', contrastStrokeColor(rgb))
1448
+ } catch (error) { /* best effort: the CSS fallback stays in place */ }
1449
+ }
1450
+ sample()
1451
+ const poll = setInterval(sample, 1500)
1452
+ return () => clearInterval(poll)
1453
+ }, [color, stroke])
1454
+ const previewStroke = strokeStyleOf(appearance)
1455
+ return E('div', { className: 'bw-appearance' },
1456
+ E('div', { className: 'bw-field' },
1457
+ t('custom.color'),
1458
+ E('div', { className: 'bw-dialog-input-row' },
1459
+ SWATCHES.map((swatch) => E('button', {
1460
+ key: swatch || 'none',
1461
+ type: 'button',
1462
+ className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
1463
+ style: swatch === '' ? undefined : { background: swatch },
1464
+ 'aria-label': swatch === '' ? t('custom.reset') : swatch,
1465
+ onClick: () => patch({ color: swatch }),
1466
+ })),
1467
+ E('input', {
1468
+ type: 'color',
1469
+ className: 'bw-color-input',
1470
+ value: color || '#5b8def',
1471
+ onChange: (e) => patch({ color: e.target.value }),
1472
+ }),
1473
+ ),
1474
+ E('div', { className: 'bw-rgb-row' },
1475
+ ['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
1476
+ label,
1477
+ E('input', {
1478
+ type: 'number',
1479
+ className: 'bw-rgb-input',
1480
+ min: 0,
1481
+ max: 255,
1482
+ value: channels ? channels[index] : '',
1483
+ placeholder: '—',
1484
+ onChange: (e) => setChannel(index, e.target.value),
1485
+ }),
1486
+ )),
1487
+ ),
1488
+ ),
1489
+ E('div', { className: 'bw-field' },
1490
+ t('custom.glow'),
1491
+ E('div', { className: 'bw-glow-row' },
1492
+ E('input', {
1493
+ type: 'range',
1494
+ className: 'bw-slider',
1495
+ min: 0,
1496
+ max: GLOW_MAX,
1497
+ step: 1,
1498
+ value: glow,
1499
+ 'aria-label': t('custom.glow'),
1500
+ onChange: (e) => patch({ glow: Number(e.target.value) }),
1501
+ }),
1502
+ E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
1503
+ ),
1504
+ ),
1505
+ E('div', { className: 'bw-field' },
1506
+ t('custom.weight'),
1507
+ E('div', { className: 'bw-seg' },
1508
+ [400, 500, 600, 700].map((wt) => E('button', {
1509
+ key: String(wt),
1510
+ type: 'button',
1511
+ className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
1512
+ onClick: () => patch({ weight: wt }),
1513
+ }, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
1514
+ ),
1515
+ ),
1516
+ E('div', { className: 'bw-field' },
1517
+ t('custom.shadow'),
1518
+ E('div', { className: 'bw-seg' },
1519
+ E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => patch({ shadow: false }) }, t('custom.none')),
1520
+ E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => patch({ shadow: true }) }, t('settings.on')),
1521
+ ),
1522
+ ),
1523
+ E('div', { className: 'bw-field' },
1524
+ t('custom.stroke'),
1525
+ E('div', { className: 'bw-seg' },
1526
+ E('button', { type: 'button', className: cls('bw-seg-btn', !stroke && 'bw-seg-btn-active'), onClick: () => patch({ stroke: false }) }, t('settings.off')),
1527
+ E('button', { type: 'button', className: cls('bw-seg-btn', stroke && 'bw-seg-btn-active'), onClick: () => patch({ stroke: true }) }, t('settings.on')),
1528
+ ),
1529
+ E('div', { className: 'bw-glow-row' },
1530
+ E('input', {
1531
+ type: 'range',
1532
+ className: 'bw-slider',
1533
+ min: 0.5,
1534
+ max: STROKE_MAX,
1535
+ step: 0.5,
1536
+ value: strokeWidth,
1537
+ 'aria-label': t('custom.strokeWidth'),
1538
+ onChange: (e) => patch({ strokeWidth: Number(e.target.value) }),
1539
+ }),
1540
+ E('span', { className: 'bw-glow-value' }, strokeWidth + 'px'),
1541
+ ),
1542
+ ),
1543
+ E('div', { className: 'bw-field' },
1544
+ t('custom.strokeColor'),
1545
+ E('div', { className: 'bw-dialog-input-row' },
1546
+ E('button', {
1547
+ type: 'button',
1548
+ className: cls('bw-swatch', 'bw-swatch-wide', strokeColor === STROKE_AUTO && 'bw-swatch-active'),
1549
+ title: t('custom.strokeColor.auto'),
1550
+ onClick: () => patch({ strokeColor: STROKE_AUTO }),
1551
+ }, t('custom.strokeColor.auto')),
1552
+ STROKE_PRESETS.map((value) => E('button', {
1553
+ key: value,
1554
+ type: 'button',
1555
+ className: cls('bw-swatch', strokeColor === value && 'bw-swatch-active'),
1556
+ style: { background: value },
1557
+ 'aria-label': value,
1558
+ title: value,
1559
+ onClick: () => patch({ strokeColor: value }),
1560
+ })),
1561
+ E('input', {
1562
+ type: 'color',
1563
+ className: 'bw-color-input',
1564
+ value: colorToRgb(strokeColor) ? strokeColor : DEFAULT_APPEARANCE.strokeColor,
1565
+ onChange: (e) => patch({ strokeColor: e.target.value }),
1566
+ }),
1567
+ ),
1568
+ E('div', { className: 'bw-hint' }, t('custom.stroke.hint')),
1569
+ ),
1570
+ allowIcon ? E('div', { className: 'bw-field' },
1571
+ t('custom.icon'),
1572
+ E('div', { className: 'bw-icon-grid' },
1573
+ ICON_CHOICES.map((mode) => E('button', {
1574
+ key: mode,
1575
+ type: 'button',
1576
+ className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
1577
+ title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1578
+ 'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1579
+ onClick: () => patch({ icon: mode }),
1580
+ }, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
1581
+ ),
1582
+ ) : null,
1583
+ E('div', { className: 'bw-field' },
1584
+ t('custom.preview'),
1585
+ E('div', {
1586
+ ref: previewRef,
1587
+ className: 'bw-preview',
1588
+ style: {
1589
+ color: color || undefined,
1590
+ fontWeight: weight > 0 ? weight : undefined,
1591
+ textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
1592
+ ...(previewStroke || null),
1593
+ },
1594
+ },
1595
+ allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
1596
+ E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
1597
+ ),
1598
+ ),
1599
+ )
1600
+ }
1601
+
1602
+ /**
1603
+ * Per-row appearance dialog: the shared controls plus the commit / reset
1604
+ * semantics. Committing exactly the default appearance (with the default
1605
+ * solid icon) clears the row's entry instead of pinning a redundant copy;
1606
+ * Reset removes the entry outright.
1607
+ */
1608
+ function CustomizeDialog({ open, initial, defaults, kind, onChange, onReset, onClose, t }) {
1289
1609
  // Icons render only for workspace / workspace-folder rows. Session rows
1290
1610
  // already carry the official status dot (pending/running/done) in the
1291
1611
  // leading slot, and session sub-group rows have no icon either — so the
1292
1612
  // icon grid is offered only where it actually displays.
1293
1613
  const allowIcon = kind === 'folder' || kind === 'workspace'
1294
- const [color, setColor] = React.useState('')
1295
- const [glow, setGlow] = React.useState(0)
1296
- const [iconMode, setIconMode] = React.useState('solid')
1297
- const [weight, setWeight] = React.useState(0)
1298
- const [shadow, setShadow] = React.useState(false)
1614
+ const fallback = readAppearance(defaults)
1615
+ const [draft, setDraft] = React.useState(DEFAULT_APPEARANCE)
1299
1616
  React.useEffect(() => {
1300
1617
  if (!open) return
1301
- setColor(initial && initial.color ? initial.color : '')
1302
- setGlow(initial && initial.glow ? Number(initial.glow) || 0 : 0)
1303
- setIconMode(initial && initial.icon ? initial.icon : 'solid')
1304
- setWeight(initial && initial.weight ? Number(initial.weight) || 0 : 0)
1305
- setShadow(initial && initial.shadow === true)
1618
+ const base = readAppearance(initial)
1619
+ setDraft({ ...base, icon: (initial && initial.icon) || 'solid' })
1306
1620
  }, [open, initial])
1307
1621
  if (!open) return null
1622
+ const unchanged = APPEARANCE_FIELDS.every((field) => draft[field] === fallback[field])
1623
+ && (!allowIcon || (draft.icon || 'solid') === 'solid')
1308
1624
  const commit = () => {
1309
- const empty = allowIcon
1310
- ? (color === '' && glow === 0 && iconMode === 'solid' && weight === 0 && !shadow)
1311
- : (color === '' && glow === 0 && weight === 0 && !shadow)
1312
- const style = empty
1313
- ? null
1314
- : { color, glow, weight: weight > 0 ? weight : undefined, shadow: shadow || undefined, ...(allowIcon ? { icon: iconMode } : {}) }
1315
- onChange(style)
1625
+ const entry = {
1626
+ color: draft.color,
1627
+ glow: draft.glow,
1628
+ weight: draft.weight > 0 ? draft.weight : undefined,
1629
+ shadow: draft.shadow || undefined,
1630
+ stroke: draft.stroke,
1631
+ strokeWidth: draft.strokeWidth,
1632
+ ...(allowIcon ? { icon: draft.icon || 'solid' } : {}),
1633
+ }
1634
+ onChange(unchanged ? null : entry)
1316
1635
  onClose()
1317
1636
  }
1318
- const channels = colorToRgb(color)
1319
- const setChannel = (index, raw) => {
1320
- const n = Math.max(0, Math.min(255, parseInt(raw, 10) || 0))
1321
- const base = channels || [0, 0, 0]
1322
- const next = base.slice()
1323
- next[index] = n
1324
- setColor(rgbToHex(next[0], next[1], next[2]))
1325
- }
1326
- const previewShadows = []
1327
- if (glow > 0 && color) previewShadows.push('0 0 ' + glow + 'px ' + color)
1328
- if (shadow) previewShadows.push('1px 1px 2px rgba(0,0,0,.85)')
1329
1637
  return E(ui.Modal, {
1330
1638
  open: true,
1331
1639
  onClose,
@@ -1337,100 +1645,12 @@ window.__ModuleLoader__.load({
1337
1645
  ),
1338
1646
  },
1339
1647
  E('div', { className: 'bw-modal-body' },
1340
- E('div', { className: 'bw-field' },
1341
- t('custom.color'),
1342
- E('div', { className: 'bw-dialog-input-row' },
1343
- SWATCHES.map((swatch) => E('button', {
1344
- key: swatch || 'none',
1345
- type: 'button',
1346
- className: cls('bw-swatch', color === swatch && 'bw-swatch-active'),
1347
- style: swatch === '' ? undefined : { background: swatch },
1348
- 'aria-label': swatch === '' ? t('custom.reset') : swatch,
1349
- onClick: () => setColor(swatch),
1350
- })),
1351
- E('input', {
1352
- type: 'color',
1353
- className: 'bw-color-input',
1354
- value: color || '#5b8def',
1355
- onChange: (e) => setColor(e.target.value),
1356
- }),
1357
- ),
1358
- E('div', { className: 'bw-rgb-row' },
1359
- ['R', 'G', 'B'].map((label, index) => E('label', { key: label, className: 'bw-rgb-label' },
1360
- label,
1361
- E('input', {
1362
- type: 'number',
1363
- className: 'bw-rgb-input',
1364
- min: 0,
1365
- max: 255,
1366
- value: channels ? channels[index] : '',
1367
- placeholder: '—',
1368
- onChange: (e) => setChannel(index, e.target.value),
1369
- }),
1370
- )),
1371
- ),
1372
- ),
1373
- E('div', { className: 'bw-field' },
1374
- t('custom.glow'),
1375
- E('div', { className: 'bw-glow-row' },
1376
- E('input', {
1377
- type: 'range',
1378
- className: 'bw-slider',
1379
- min: 0,
1380
- max: GLOW_MAX,
1381
- step: 1,
1382
- value: glow,
1383
- 'aria-label': t('custom.glow'),
1384
- onChange: (e) => setGlow(Number(e.target.value)),
1385
- }),
1386
- E('span', { className: 'bw-glow-value' }, glow === 0 ? t('custom.none') : glow + 'px'),
1387
- ),
1388
- ),
1389
- E('div', { className: 'bw-field' },
1390
- t('custom.weight'),
1391
- E('div', { className: 'bw-seg' },
1392
- [400, 500, 600, 700].map((wt) => E('button', {
1393
- key: String(wt),
1394
- type: 'button',
1395
- className: cls('bw-seg-btn', weight === wt && 'bw-seg-btn-active'),
1396
- onClick: () => setWeight(wt),
1397
- }, wt === 400 ? t('custom.weight.regular') : wt === 500 ? t('custom.weight.medium') : wt === 600 ? t('custom.weight.semibold') : t('custom.weight.bold'))),
1398
- ),
1399
- ),
1400
- E('div', { className: 'bw-field' },
1401
- t('custom.shadow'),
1402
- E('div', { className: 'bw-seg' },
1403
- E('button', { type: 'button', className: cls('bw-seg-btn', !shadow && 'bw-seg-btn-active'), onClick: () => setShadow(false) }, t('custom.none')),
1404
- E('button', { type: 'button', className: cls('bw-seg-btn', shadow && 'bw-seg-btn-active'), onClick: () => setShadow(true) }, t('settings.on')),
1405
- ),
1406
- ),
1407
- allowIcon ? E('div', { className: 'bw-field' },
1408
- t('custom.icon'),
1409
- E('div', { className: 'bw-icon-grid' },
1410
- ICON_CHOICES.map((mode) => E('button', {
1411
- key: mode,
1412
- type: 'button',
1413
- className: cls('bw-icon-cell', iconMode === mode && 'bw-icon-cell-active'),
1414
- title: mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1415
- 'aria-label': mode === 'solid' ? t('custom.icon.solid') : (mode === 'outline' ? t('custom.icon.outline') : mode),
1416
- onClick: () => setIconMode(mode),
1417
- }, mode === 'none' ? E('span', { className: 'bw-icon-none' }) : iconOf(mode, false))),
1418
- ),
1419
- ) : null,
1420
- E('div', { className: 'bw-field' },
1421
- t('custom.preview'),
1422
- E('div', {
1423
- className: 'bw-preview',
1424
- style: {
1425
- color: color || undefined,
1426
- fontWeight: weight > 0 ? weight : undefined,
1427
- textShadow: previewShadows.length > 0 ? previewShadows.join(',') : undefined,
1428
- },
1429
- },
1430
- allowIcon ? E('span', { className: 'bw-preview-icon' }, iconOf(iconMode, true)) : null,
1431
- E('span', { className: 'bw-preview-label' }, t('custom.preview.sample')),
1432
- ),
1433
- ),
1648
+ E(AppearanceControls, {
1649
+ value: draft,
1650
+ onChange: (patch) => setDraft((prev) => ({ ...readAppearance(prev), ...patch, icon: prev.icon || 'solid' })),
1651
+ allowIcon,
1652
+ t,
1653
+ }),
1434
1654
  ),
1435
1655
  StyleNode(),
1436
1656
  )
@@ -1448,10 +1668,15 @@ window.__ModuleLoader__.load({
1448
1668
  const localFolders = useStore ? (useStore(s => s.folders) || []) : []
1449
1669
  const compactChains = prefs.compactChains !== false
1450
1670
  const statusPulse = prefs.statusPulse !== false
1671
+ // Default appearance: the base every row inherits unless it carries its
1672
+ // own per-row entry. The outline ships ON — text over a background image
1673
+ // is often unreadable without it.
1674
+ const appearance = readAppearance(prefs.appearance)
1451
1675
  const setPref = (key, value) => {
1452
1676
  if (actions && typeof actions.setPref === 'function') actions.setPref(key, value)
1453
1677
  scopeSet(key, value)
1454
1678
  }
1679
+ const setAppearance = (next) => setPref('appearance', readAppearance(next))
1455
1680
  // Manual cross-device sync state.
1456
1681
  const [syncMode, setSyncMode] = React.useState('overwrite')
1457
1682
  const [syncMsg, setSyncMsg] = React.useState(null)
@@ -1471,6 +1696,7 @@ window.__ModuleLoader__.load({
1471
1696
  scopeSet('folders', folders)
1472
1697
  scopeSet('compactChains', prefs.compactChains !== false)
1473
1698
  scopeSet('statusPulse', prefs.statusPulse !== false)
1699
+ scopeSet('appearance', appearance)
1474
1700
  setSyncMsg(t('sync.done'))
1475
1701
  }
1476
1702
  const modeButton = (id, label) => E('button', {
@@ -1511,6 +1737,19 @@ window.__ModuleLoader__.load({
1511
1737
  }, E('span', { className: 'bw-switch-thumb' })),
1512
1738
  ),
1513
1739
  E('div', { className: 'bw-hint' }, t('settings.statusPulse.hint')),
1740
+ E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('settings.appearance')),
1741
+ E('div', { className: 'bw-hint' }, t('settings.appearance.hint')),
1742
+ E('div', { className: 'bw-appearance-box' },
1743
+ E(AppearanceControls, {
1744
+ value: appearance,
1745
+ onChange: (patch) => setAppearance({ ...appearance, ...patch }),
1746
+ allowIcon: false,
1747
+ t,
1748
+ }),
1749
+ E('div', { style: { marginTop: 10 } },
1750
+ E(BTN, { variant: 'outline', onClick: () => setAppearance(DEFAULT_APPEARANCE) }, t('settings.appearance.reset')),
1751
+ ),
1752
+ ),
1514
1753
  E('div', { className: 'bw-setting-label', style: { marginTop: 16 } }, t('sync.title')),
1515
1754
  E('div', { className: 'bw-hint' }, t('sync.desc')),
1516
1755
  E('div', { className: 'bw-sync-modes' },
@@ -1612,6 +1851,48 @@ window.__ModuleLoader__.load({
1612
1851
  }
1613
1852
  }, [list ? list.byId : null])
1614
1853
 
1854
+ // Outline color sampler. The outline has to contrast with the LABEL
1855
+ // color, and the palette can flip (theme, or a background plugin's
1856
+ // light/dark switch) without any React render. So sample the tree's live
1857
+ // computed color into --bw-stroke-color: attribute mutations on
1858
+ // <html>/<body> catch class- and style-driven flips at once, and the slow
1859
+ // poll catches stylesheet-only rewrites (CSSOM variable swaps). Rows with
1860
+ // their own color compute the pole in JS and never read the variable.
1861
+ const treeRef = React.useRef(null)
1862
+ React.useEffect(() => {
1863
+ const el = treeRef.current
1864
+ if (!el || typeof getComputedStyle !== 'function') return undefined
1865
+ let stopped = false
1866
+ let pending = null
1867
+ const sample = () => {
1868
+ if (stopped) return
1869
+ try {
1870
+ const rgb = parseCssColor(getComputedStyle(el).color)
1871
+ if (rgb) el.style.setProperty('--bw-stroke-color', contrastStrokeColor(rgb))
1872
+ } catch (error) { /* best effort: the CSS fallback stays in place */ }
1873
+ }
1874
+ const schedule = () => {
1875
+ if (stopped || pending !== null) return
1876
+ pending = setTimeout(() => { pending = null; sample() }, 150)
1877
+ }
1878
+ sample()
1879
+ let observer = null
1880
+ if (typeof MutationObserver === 'function') {
1881
+ observer = new MutationObserver(schedule)
1882
+ try {
1883
+ observer.observe(document.documentElement, { attributes: true })
1884
+ if (document.body && document.body !== document.documentElement) observer.observe(document.body, { attributes: true })
1885
+ } catch (error) { /* observing is optional */ }
1886
+ }
1887
+ const poll = setInterval(sample, 1500)
1888
+ return () => {
1889
+ stopped = true
1890
+ if (pending !== null) clearTimeout(pending)
1891
+ clearInterval(poll)
1892
+ if (observer) observer.disconnect()
1893
+ }
1894
+ }, [])
1895
+
1615
1896
  const [query, setQuery] = React.useState('')
1616
1897
  const [searchOpen, setSearchOpen] = React.useState(false)
1617
1898
  const [flowOpen, setFlowOpen] = React.useState(false)
@@ -1724,20 +2005,26 @@ window.__ModuleLoader__.load({
1724
2005
  const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
1725
2006
 
1726
2007
  const styleEntry = (key) => stylingMap[key] || null
2008
+ // Effective appearance = the settings card's default, overridden field by
2009
+ // field by this row's own entry.
2010
+ const defaultAppearance = readAppearance(prefsMap.appearance)
2011
+ const appearanceOf = (key) => mergeAppearance(defaultAppearance, styleEntry(key))
1727
2012
  const rowStyleOf = (key) => {
1728
- const entry = styleEntry(key)
1729
- if (!entry || !entry.color) return null
1730
- const glow = Number(entry.glow) || 0
1731
- const weight = Number(entry.weight) || 0
1732
- const shadow = entry.shadow === true
2013
+ const appearance = appearanceOf(key)
2014
+ const color = appearance.color || ''
2015
+ const glow = Number(appearance.glow) || 0
2016
+ const weight = Number(appearance.weight) || 0
2017
+ const shadow = appearance.shadow === true
1733
2018
  const shadows = []
1734
- if (glow > 0) shadows.push('0 0 ' + glow + 'px ' + entry.color)
2019
+ if (glow > 0 && color) shadows.push('0 0 ' + glow + 'px ' + color)
1735
2020
  if (shadow) shadows.push('1px 1px 2px rgba(0,0,0,.85)')
1736
- return {
1737
- color: entry.color,
1738
- fontWeight: weight > 0 ? weight : undefined,
1739
- textShadow: shadows.length > 0 ? shadows.join(',') : undefined,
1740
- }
2021
+ const style = {}
2022
+ if (color) style.color = color
2023
+ if (weight > 0) style.fontWeight = weight
2024
+ if (shadows.length > 0) style.textShadow = shadows.join(',')
2025
+ const stroke = strokeStyleOf(appearance)
2026
+ if (stroke) Object.assign(style, stroke)
2027
+ return Object.keys(style).length > 0 ? style : null
1741
2028
  }
1742
2029
  const keyOf = (kind, payload) => {
1743
2030
  if (kind === 'folder') return 'folder:' + payload.path
@@ -2521,7 +2808,7 @@ window.__ModuleLoader__.load({
2521
2808
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('search.placeholder'), onClick: () => setSearchOpen(v => !v) }, icon('IconSearchOutline16')),
2522
2809
  E('button', { type: 'button', className: 'bw-icon-btn', 'aria-label': t('add'), onClick: () => { setFlowParent(''); setFlowOpen(true) } }, icon('IconProjectAddOutline16')),
2523
2810
  ),
2524
- E('div', { className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
2811
+ E('div', { ref: treeRef, className: 'bw-tree', role: 'tree', 'aria-label': t('title') }, bodyRows),
2525
2812
  E(BetterFlow, {
2526
2813
  open: flowOpen,
2527
2814
  busy: false,
@@ -2563,7 +2850,10 @@ window.__ModuleLoader__.load({
2563
2850
  E(CustomizeDialog, {
2564
2851
  open: customize !== null,
2565
2852
  kind: customize ? customize.kind : undefined,
2566
- initial: customize ? styleEntry(customize.entryKey) : undefined,
2853
+ defaults: defaultAppearance,
2854
+ initial: customize
2855
+ ? { ...appearanceOf(customize.entryKey), icon: (styleEntry(customize.entryKey) || {}).icon || 'solid' }
2856
+ : undefined,
2567
2857
  onChange: (style) => { if (customize) shared.setStyling(customize.entryKey, style) },
2568
2858
  onReset: () => { if (customize) shared.setStyling(customize.entryKey, null) },
2569
2859
  onClose: () => setCustomize(null),
package/src/index.js CHANGED
@@ -59,6 +59,11 @@ export function apply(ctx) {
59
59
  statusPulse: Schema.boolean().default(true),
60
60
  folders: Schema.array(Schema.string()).default([]),
61
61
  styling: Schema.dict(Schema.any()).default({}),
62
+ // Default appearance (color / glow / weight / shadow / outline) for
63
+ // rows without their own entry — shared with the other surface like
64
+ // the rest of the prefs. The client half owns the shape, so it stays
65
+ // a permissive dict.
66
+ appearance: Schema.dict(Schema.any()).default({}),
62
67
  }))
63
68
  log('[dsh-better-workspace] settings namespace registered: better-workspace')
64
69
  })