dsh-theme-neumorph 1.0.1 → 1.0.3

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.
Files changed (3) hide show
  1. package/README.md +21 -12
  2. package/client.js +119 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # DSH 新拟态主题 (dsh-theme-neumorph)
2
2
 
3
3
  [![CI](https://github.com/wcnmamrs/dsh-theme-neumorph/actions/workflows/ci.yml/badge.svg)](https://github.com/wcnmamrs/dsh-theme-neumorph/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/dsh-theme-neumorph.svg)](https://www.npmjs.com/package/dsh-theme-neumorph)
5
+ [![license](https://img.shields.io/npm/l/dsh-theme-neumorph.svg)](LICENSE)
4
6
 
5
7
  给 Harness 网页界面换上一层**新拟态**(neumorphism / soft UI)皮肤:冷灰表面、钢蓝强调色、柔和的单向与双向软阴影,浅色与深色模式同时生效。
6
8
 
@@ -10,7 +12,7 @@
10
12
 
11
13
  | 首页与输入区 | 侧栏与会话列表 |
12
14
  | --- | --- |
13
- | ![首页](assets/screenshot-1.jpg) | ![侧栏](assets/screenshot-2.jpg) |
15
+ | ![首页](https://raw.githubusercontent.com/wcnmamrs/dsh-theme-neumorph/main/assets/screenshot-1.jpg) | ![侧栏](https://raw.githubusercontent.com/wcnmamrs/dsh-theme-neumorph/main/assets/screenshot-2.jpg) |
14
16
 
15
17
  ## 它做了什么
16
18
 
@@ -32,9 +34,11 @@
32
34
 
33
35
  主题只动宿主自己画的界面,不碰第三方插件:
34
36
 
35
- - 宿主的类名是 CSS Module 的 `<hash>_<local>` 形式,规则按整类名锚定(如 `[class$="_card"]`),不会误伤卡片内部的零件。
36
- - 插件的界面用自己的命名(`dshwv-*`、BEM 之类),不被命中。
37
- - 没有一条规则按裸标签生效:`span` 不被当成文字,`button` 不被当成按钮。
37
+ - 宿主同时用着**两套** CSS Module 命名:预构建样式表是 `_<local>_<hash>_<line>`(`_card_7gxqk_107`),组件从 JS 注入的样式是 `<hash>_<local>`(`wSkVaW_card`)。所以一个类名要同时锚定两次——`[class$="_card"]` 与 `[class*="_card_"]`——只写一种就会有一半界面纹丝不动。
38
+ - 子串锚定一定用下划线夹住(`[class*="_card_"]` 而不是 `[class*="_card"]`),否则会串到 `_cardContent_x_y` 这种更长的零件上,卡片的每一块又各得一层投影。
39
+ - 每个类名锚定都带 `:not([class*="__"])`:宿主是**单**下划线,别人家的 BEM 是**双**下划线,这一条挡住的正是 `panel__card`。排除项(`button:not([class*="_row"])` 这类)不加,因为排除范围宽一点只会少上一条浮雕。
40
+ - 裸标签只出现在两类地方:宿主的表单与代码面(`textarea`、`input`、`select`、`pre`),以及必须同时带宿主类名的控件(`button` / `[role="button"]` + `[class*="_"]`)。`span` 不被当成文字。
41
+ - 文本框的焦点指示不用宿主的外扩轮廓(在软表面上像一圈多余的蓝边),改成画在框内侧的 1.5px 钢蓝内缘;`outline-offset: -2px` 与内凹阴影叠加而不是替换它。checkbox / radio / 原生控件完全不碰,宿主自己的焦点环留给它们。
38
42
 
39
43
  ## 目录结构
40
44
 
@@ -57,26 +61,31 @@
57
61
  ## 实现要点
58
62
 
59
63
  - Client 半通过 `window.__ModuleLoader__.load({ id, factory })` 注册,`factory` 返回 `{ inject: ['theme'], apply(ctx) }`。
60
- - 所有副作用都包在 `ctx.effect()` 里并返回清理函数,插件停用时撤销 token 覆盖层并移除样式表,界面立刻回到原生外观。
61
- - 若宿主某天拒绝含 `--dsw-radius-*` 的 token 表,会自动退回只含颜色的 58 项重试。
62
- - 样式规则用整类名锚定宿主 CSS Module,哈希前缀随版本变化也不会失效。
64
+ - 所有副作用都包在 `ctx.effect()` 里并返回清理函数:样式表在 effect 内插入(effect 被拒就一张纸都不留),重复 apply 时先删掉自己上一张,停用时先移除样式表再撤销 token 覆盖层,界面立刻回到原生外观。
65
+ - 若宿主某天拒绝含 `--dsw-radius-*` 的 token 表,会自动退回只含颜色的 66 项重试;连重试也被拒时保留阴影层并告警,而不是把主题整个掀掉。
66
+ - 样式规则用宿主类名锚定 CSS Module,哈希前缀与文件名随版本变化都不会失效。
63
67
 
64
68
  ## 开发
65
69
 
66
70
  不需要浏览器,也不需要安装依赖(`node --test` 自带):
67
71
 
68
72
  ```bash
69
- npm test # 7 项断言:调色板、样式表结构、卸载路径
73
+ npm test # 15 项断言:调色板、样式表结构、生命周期、卸载路径
70
74
  npm run check # 语法检查
71
75
  node scripts/verify-manifest.mjs # 清单/打包自检
72
76
  npm run verify # 以上全部
73
77
  ```
74
78
 
75
- 测试用一个假的 `window.__ModuleLoader__` 与 `document` 载入 `client.js`,因此断言的正是宿主实际调用的那条路径。其中三条是这次踩过坑之后加的护栏:
79
+ 测试用一个假的 `window.__ModuleLoader__` 与 `document` 载入 `client.js`,因此断言的正是宿主实际调用的那条路径。这个假 `document` 会记录"谁在什么时机被插进哪个根",所以下面这些都能测出来:
76
80
 
77
- - 样式表里不允许出现裸标签选择器(`body span`、`body button`),
78
- - 不允许出现第三方插件命名(曾经把 `dshwv-*` 的图标清空),
79
- - 卡片只允许整类名锚定,不允许 `[class*="_card"]` 这种会命中 `_cardContent` 的子串锚定。
81
+ - 样式表没有按裸标签生效(`span` 不被当成文字),
82
+ - 没有第三方插件命名(曾经把 `dshwv-*` 的图标清空),
83
+ - 类名锚定必须用下划线夹住,且每个 local 的两套命名都在(只写一套是曾经踩过的坑:一半界面没有浮雕),
84
+ - 每个锚定都带 BEM 护栏;排除项不算,
85
+ - 焦点环存在、画在框内、不吞掉内凹阴影、不碰 checkbox / radio,且全文没有一处 `outline: none`,
86
+ - 样式表在 `ctx.effect` 内插入:宿主拒绝注册 effect 时一张纸都不留,
87
+ - 连续 apply 两次只留下一张样式表,
88
+ - 浮雕落在卡片、输入区大板、菜单/对话框与会话行上,且各用各的阴影级。
80
89
 
81
90
  ## 许可
82
91
 
package/client.js CHANGED
@@ -137,6 +137,39 @@ window.__ModuleLoader__.load({
137
137
  '--dsw-radius-panel': { light: '26px', dark: '26px' },
138
138
  };
139
139
 
140
+ /**
141
+ * The host ships two CSS-module naming conventions at once:
142
+ *
143
+ * - the pre-built app stylesheet names its classes `_<local>_<hash>_<line>`
144
+ * (`_card_7gxqk_107`);
145
+ * - the component styles injected from JS name theirs `<hash>_<local>`
146
+ * (`wSkVaW_card`).
147
+ *
148
+ * So an anchor for a local name has to cover both, and it must not straddle
149
+ * a longer local: `_card_` never matches `_cardContent_1a2b_9`, while a bare
150
+ * `[class*="_card"]` would. Every anchor also refuses a double underscore,
151
+ * because that is how a plugin names its own elements (`panel__card`) and no
152
+ * host class contains one.
153
+ */
154
+ const guard = (anchor) => `${anchor}:not([class*="__"])`;
155
+
156
+ /** Anchors on a free-form class fragment (`_title`, `_label`). */
157
+ const sub = (...fragments) =>
158
+ fragments.map((fragment) => guard(`[class*="${fragment}"]`)).join(',\n ');
159
+
160
+ /** Anchors on a complete local name, in both host conventions. */
161
+ const host = (...locals) =>
162
+ locals
163
+ .flatMap((local) => [`[class$="_${local}"]`, `[class*="_${local}_"]`])
164
+ .map(guard)
165
+ .join(',\n ');
166
+
167
+ /** A host button: anything the host painted with a class, minus the cases
168
+ * below. Kept as one constant so the three states cannot drift apart. */
169
+ const BUTTON_SELECTOR = `body :where(button, [role="button"])[class*="_"]:not([class*="__"]):not([aria-checked]):not([role="checkbox"]):not([role="radio"]):not(:is(${host(
170
+ 'primary',
171
+ )})):not([role="menuitem"]):not([role="option"]):not([role="tab"]):not([class*="_callRow"]):not([class*="_panelRow"]):not([class*="_cardContent"]):not([class*="_row"]):not([class*="_tab"]):not([class*="_option"]):not([class*="_cell"]):not([class*="_switcher"]):not([class*="_segmented"]):not(:is([role="menu"], [role="tablist"], [role="listbox"], [data-menu-material], [class*="_tabs"], [class*="_groups"], [class*="_menu"]) *)`;
172
+
140
173
  const CSS = `
141
174
  body {
142
175
  --qn-hi: #ffffff;
@@ -163,14 +196,26 @@ body[data-ds-dark-theme] {
163
196
  }
164
197
 
165
198
  /* ---- text is flat ------------------------------------------------------
166
- Only elements the harness itself styles are touched, and only by name:
167
- every host class is a CSS-module pair of the form <hash>_<local>, so a
168
- class that contains an underscore is host markup. Plugin UI names its own
169
- elements (dshwv-menu-btn and friends) and is left alone.
170
- Nothing here targets a bare tag: a span is not text until it says so. */
171
- /* Same rule, for the pieces of text that are wrapped in a div rather than a
172
- span: a label, a title, a value. They are content too. */
173
- body :is([class*="_label"], [class*="_title"], [class*="_value"], [class*="_text"], [class*="_name"], [class*="_desc"], [class*="_caption"], [class*="_hint"], [class*="_meta"], [class*="_timestamp"]) {
199
+ Text never carries a plate of its own: a label sits on the surface, it does
200
+ not sit in a slightly different rectangle. Only host markup is touched, and
201
+ only by the name the host gives it (see the host/sub helpers above), so a plugin's
202
+ own elements are never repainted. Nothing here targets a bare tag: a span is
203
+ not text until the host says so. */
204
+ body :is(${sub('_label', '_title', '_value', '_text', '_name', '_desc', '_caption', '_hint', '_meta', '_timestamp')}, ${host(
205
+ 'itemLabel',
206
+ 'runStateLabel',
207
+ 'dockHintLabel',
208
+ 'clickableLabel',
209
+ 'labelGroup',
210
+ 'tabTitle',
211
+ 'floatTitle',
212
+ 'failedTitle',
213
+ 'stringValue',
214
+ 'numberValue',
215
+ 'otherValue',
216
+ 'keywordValue',
217
+ 'popoverText',
218
+ )}) {
174
219
  background-color: transparent !important;
175
220
  box-shadow: none !important;
176
221
  }
@@ -181,33 +226,33 @@ body :is([class*="_label"], [class*="_title"], [class*="_value"], [class*="_text
181
226
  lives inside a menu, a tab strip or a picker are excluded, because a
182
227
  shadow under a row that spans the panel reads as a stray line, and a
183
228
  stack of them reads as alternating high and low. */
184
- body :where(button, [role="button"])[class*="_"]:not([aria-checked]):not([role="checkbox"]):not([role="radio"]):not([class*="_primary"]):not([role="menuitem"]):not([role="option"]):not([role="tab"]):not([class*="_callRow"]):not([class*="_panelRow"]):not([class*="_cardContent"]):not([class*="_row"]):not([class*="_tab"]):not([class*="_option"]):not([class*="_cell"]):not([class*="_switcher"]):not([class*="_segmented"]):not(:is([role="menu"], [role="tablist"], [role="listbox"], [data-menu-material], [class*="_tabs"], [class*="_groups"], [class*="_menu"]) *) {
229
+ ${BUTTON_SELECTOR} {
185
230
  border-radius: 12px !important;
186
231
  border-color: transparent !important;
187
232
  background-color: var(--dsw-alias-bg-base) !important;
188
233
  box-shadow: var(--qn-out-sm) !important;
189
234
  }
190
235
 
191
- body :where(button, [role="button"])[class*="_"]:not([aria-checked]):not([role="checkbox"]):not([role="radio"]):not([class*="_primary"]):not([role="menuitem"]):not([role="option"]):not([role="tab"]):not([class*="_callRow"]):not([class*="_panelRow"]):not([class*="_cardContent"]):not([class*="_row"]):not([class*="_tab"]):not([class*="_option"]):not([class*="_cell"]):not([class*="_switcher"]):not([class*="_segmented"]):not(:is([role="menu"], [role="tablist"], [role="listbox"], [data-menu-material], [class*="_tabs"], [class*="_groups"], [class*="_menu"]) *):hover:not(:disabled) {
236
+ ${BUTTON_SELECTOR}:hover:not(:disabled) {
192
237
  box-shadow: var(--qn-out) !important;
193
238
  }
194
239
 
195
- body :where(button, [role="button"])[class*="_"]:not([aria-checked]):not([role="checkbox"]):not([role="radio"]):not([class*="_primary"]):not([role="menuitem"]):not([role="option"]):not([role="tab"]):not([class*="_callRow"]):not([class*="_panelRow"]):not([class*="_cardContent"]):not([class*="_row"]):not([class*="_tab"]):not([class*="_option"]):not([class*="_cell"]):not([class*="_switcher"]):not([class*="_segmented"]):not(:is([role="menu"], [role="tablist"], [role="listbox"], [data-menu-material], [class*="_tabs"], [class*="_groups"], [class*="_menu"]) *):active:not(:disabled) {
240
+ ${BUTTON_SELECTOR}:active:not(:disabled) {
196
241
  box-shadow: var(--qn-in-sm) !important;
197
242
  }
198
243
 
199
244
  /* The send button is a filled pill and keeps one soft shadow of its own. */
200
- body :where(button, [role="button"])[class*="_primary"] {
245
+ body :where(button, [role="button"]):is(${host('primary')}) {
201
246
  border-radius: 999px !important;
202
247
  border-color: transparent !important;
203
248
  box-shadow: 0 3px 8px rgba(74, 107, 138, 0.28) !important;
204
249
  }
205
250
 
206
- body :where(button, [role="button"])[class*="_primary"]:hover:not(:disabled) {
251
+ body :where(button, [role="button"]):is(${host('primary')}):hover:not(:disabled) {
207
252
  box-shadow: 0 4px 10px rgba(74, 107, 138, 0.34) !important;
208
253
  }
209
254
 
210
- body :where(button, [role="button"])[class*="_primary"]:active:not(:disabled) {
255
+ body :where(button, [role="button"]):is(${host('primary')}):active:not(:disabled) {
211
256
  box-shadow: inset 2px 2px 5px rgba(0, 0, 0, 0.26) !important;
212
257
  }
213
258
 
@@ -226,16 +271,14 @@ body :where(
226
271
  Deliberately narrow. Rows, tabs, chips, list items and every other small
227
272
  block stay flat: a shadow on each of them turns the page into a field of
228
273
  pillows, which is not what neumorphism is for. */
229
- body [class$="_card"], [class*="_card "], [class$="Card"], [class*="Card "] {
274
+ body :is(${host('card')}) {
230
275
  box-shadow: var(--qn-drop) !important;
231
276
  border-color: transparent !important;
232
277
  }
233
278
 
234
279
  /* ---- composer: the one big soft slab ------------------------------------ */
235
- body [class*="_composerSeat"] [class$="_card"],
236
- body [class*="_composerSeat"] [class*="_card "],
237
- body [class*="_composerHero"] [class$="_card"],
238
- body [class*="_composerHero"] [class*="_card "] {
280
+ body :is(${host('composerSeat')}) :is(${host('card')}),
281
+ body :is(${host('composerHero')}) :is(${host('card')}) {
239
282
  box-shadow: var(--qn-out) !important;
240
283
  border-radius: 22px !important;
241
284
  border-color: transparent !important;
@@ -244,7 +287,7 @@ body [class*="_composerHero"] [class*="_card "] {
244
287
  /* ---- floating surfaces: menus, dialogs, tooltips ------------------------ */
245
288
  body :where([role="menu"], [role="dialog"], [role="listbox"], [role="tooltip"]),
246
289
  body [data-menu-material],
247
- body :is([class$="_menu"], [class*="_menu "], [class$="_dialog"], [class*="_dialog "]) {
290
+ body :is(${host('menu', 'dialog')}) {
248
291
  border-radius: 20px !important;
249
292
  box-shadow: var(--qn-out-lg) !important;
250
293
  }
@@ -255,19 +298,13 @@ body [data-menu-material] > [aria-hidden="true"] {
255
298
  border-radius: inherit !important;
256
299
  }
257
300
 
258
- body [data-menu-material] {
259
- overflow: hidden !important;
260
- }
261
-
262
301
  /* ---- session tree: one soft row per session ----------------------------- */
263
- body [class*="_sessionRow"],
264
- body [class*="_projectRow"] {
302
+ body :is(${host('sessionRow', 'projectRow')}) {
265
303
  border-radius: 12px !important;
266
304
  box-shadow: var(--qn-drop) !important;
267
305
  }
268
306
 
269
- body [class*="_sessionRow"]:hover,
270
- body [class*="_projectRow"]:hover {
307
+ body :is(${host('sessionRow', 'projectRow')}):hover {
271
308
  box-shadow: var(--qn-drop-lift) !important;
272
309
  }
273
310
 
@@ -279,34 +316,38 @@ body :where(pre) {
279
316
  }
280
317
 
281
318
  /* ---- sidebar: a faint groove instead of a divider line ------------------ */
282
- body [class*="_sidebarCol"] {
319
+ body :is(${host('sidebarCol')}) {
283
320
  box-shadow: inset -8px 0 14px -14px var(--qn-lo) !important;
284
321
  }
285
322
 
286
- /* ---- focus: visible on controls, never a ring on the text fields -------- */
287
- body :where(textarea, input, select, [contenteditable="true"]):focus,
288
- body :where(textarea, input, select, [contenteditable="true"]):focus-visible {
289
- outline: none !important;
323
+ /* ---- focus: a ring, drawn inside the field -----------------------------
324
+ The host draws its ring outside the box, which on a soft surface reads as a
325
+ stray edge floating beside the field. So the ring is moved inward instead of
326
+ removed: an outline with a negative offset follows the border radius, composes
327
+ with the recessed shadow instead of replacing it, and still exists for every
328
+ control type - including the checkable ones this sheet never restyles, which
329
+ keep the host ring untouched. A theme that sets outline: none on a focus
330
+ state leaves a keyboard user with no idea where they are; this one does not. */
331
+ body :where(
332
+ textarea,
333
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]),
334
+ select,
335
+ [contenteditable="true"]
336
+ ):focus-visible {
337
+ outline: 1.5px solid var(--dsw-alias-brand-primary) !important;
338
+ outline-offset: -2px !important;
290
339
  }
291
340
 
292
341
  body :where(button, [role="button"], a):focus-visible {
293
342
  outline: 2px solid var(--dsw-alias-brand-primary);
294
343
  outline-offset: 2px;
295
344
  }
296
-
297
345
  `;
298
346
 
299
347
  return {
300
348
  inject: ['theme'],
301
349
  apply(ctx) {
302
- // Shadow layer: neumorphism needs paired soft shadows, and a theme token
303
- // can only carry a colour, so the shadows are a stylesheet of their own.
304
- const style = document.createElement('style');
305
- style.dataset.dshTheme = SOURCE;
306
- style.textContent = CSS;
307
- document.head.appendChild(style);
308
-
309
- // Colour layer: `overrideTokens` returns the disposer for exactly this
350
+ // Colour table: `overrideTokens` returns the disposer for exactly this
310
351
  // layer. The radius tokens are the only non-colour entries, so they are
311
352
  // what gets dropped first if a host build ever rejects the table.
312
353
  const overrideTokens = () => {
@@ -321,11 +362,45 @@ body :where(button, [role="button"], a):focus-visible {
321
362
  }
322
363
  };
323
364
 
365
+ // Everything the plugin adds is created and released inside one effect:
366
+ // a stylesheet inserted before the effect registered would outlive a
367
+ // throw, and a second apply would stack a second copy of it. So the old
368
+ // sheet of this source is dropped first, and a throw after insertion
369
+ // takes the sheet back out again.
324
370
  ctx.effect(() => {
325
- const disposeTokens = overrideTokens();
371
+ const root = document.head ?? document.documentElement;
372
+ for (const stale of document.querySelectorAll?.(`style[data-dsh-theme="${SOURCE}"]`) ?? []) {
373
+ stale.remove();
374
+ }
375
+
376
+ // Shadow layer: neumorphism needs paired soft shadows, and a theme
377
+ // token can only carry a colour, so the shadows are a stylesheet of
378
+ // their own.
379
+ const style = document.createElement('style');
380
+ style.dataset.dshTheme = SOURCE;
381
+ style.textContent = CSS;
382
+
383
+ let disposeTokens = null;
384
+ try {
385
+ root.appendChild(style);
386
+ disposeTokens = overrideTokens();
387
+ } catch (error) {
388
+ // Whatever happened, this effect owns what it inserted: give it back
389
+ // before getting out of the way. The one exception is the token
390
+ // table, which has a colour-only retry of its own; if even that is
391
+ // rejected the shadows are still worth keeping, so report and carry on.
392
+ if (style.parentNode === null && disposeTokens === null) {
393
+ style.remove();
394
+ throw error;
395
+ }
396
+ console.warn('[dsh-theme-neumorph] token layer rejected, shadows only', error);
397
+ }
398
+
326
399
  return () => {
327
- disposeTokens();
400
+ // Sheet first: one frame of host colours is better than one frame of
401
+ // plugin shadows over the host's own palette.
328
402
  style.remove();
403
+ disposeTokens?.();
329
404
  };
330
405
  });
331
406
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theme-neumorph",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "DSH 新拟态主题插件:冷灰表面、钢蓝强调、柔和阴影。",
5
5
  "license": "MIT",
6
6
  "author": "wcnmamrs",