dsh-better-setting-page 0.0.1 → 0.1.0
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 +132 -10
- package/README.zh.md +123 -0
- package/client.js +694 -10
- package/cordis.patch.yml +6 -2
- package/index.js +5 -5
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -1,20 +1,142 @@
|
|
|
1
|
-
# Better Setting Page
|
|
1
|
+
# Better Setting Page
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Makes the DeepSeek Harness settings dialog a full-window page, and lets its
|
|
4
|
+
section rail collapse to a 56px icon column.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Settings in this harness open as an 800px-wide modal. That is fine for three
|
|
7
|
+
rows of switches and cramped for anything else — a category page with a card
|
|
8
|
+
per category is unreadable at that width, and the wasted screen is the user's.
|
|
9
|
+
This plugin does not add settings; it gives the page the room it should have had.
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
Until then, nothing is registered and nothing is styled.
|
|
11
|
+
Verified against harness `0.2.2-rc.1` (`@deepseek-ai/dsh-client-ui-settings-general`
|
|
12
|
+
0.2.2-rc.1 in the unpacked asar) — see [How it works](#how-it-works) for the exact
|
|
13
|
+
anchors, so you can tell whether a later build still matches.
|
|
13
14
|
|
|
14
15
|
## Install
|
|
15
16
|
|
|
16
17
|
dsh plugin --profile <profile> add dsh-better-setting-page
|
|
17
18
|
|
|
19
|
+
Restart that profile's window afterwards: the browser caches the old bundle.
|
|
20
|
+
|
|
21
|
+
## What you get
|
|
22
|
+
|
|
23
|
+
| | |
|
|
24
|
+
|---|---|
|
|
25
|
+
| Full-window page | The dialog fills the window; the panel radius and drop shadow go away. On desktop the title-bar height is kept as padding, and that strip still drags the window. |
|
|
26
|
+
| Collapsible rail | A chevron in the page header collapses the section list to 56px of icons. Section names stay in the accessibility tree, so screen readers and `Tab` still announce them. |
|
|
27
|
+
| Remembered in this browser | Both switches live in this browser profile's `localStorage`, key `dsh.better-setting-page.settings`. |
|
|
28
|
+
| Its own settings page | Under **Settings → Settings page** you get two switches and **Restore defaults**. Without that page, a user who dislikes full screen would have to uninstall the plugin. |
|
|
29
|
+
|
|
30
|
+
Full screen is **on by default**; the rail is **expanded by default**. Both are
|
|
31
|
+
switchable, and both choices survive a restart.
|
|
32
|
+
|
|
33
|
+
Closing works exactly as it did before: `Esc`, the close button, and a click on
|
|
34
|
+
the mask. The mask is still there and still clickable — with full screen on it is
|
|
35
|
+
simply covered by the panel, so in practice `Esc` and the close button are what
|
|
36
|
+
you reach for.
|
|
37
|
+
|
|
38
|
+
The rail is collapsed by CSS only; the section names are visually hidden, not
|
|
39
|
+
removed, so `Tab` still lands on each entry and a screen reader still announces
|
|
40
|
+
it. There is no hover-to-expand: the arrow toggles, nothing else moves.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
Everything hangs off two attributes this plugin owns on `<html>`:
|
|
45
|
+
|
|
46
|
+
<html data-bsp-fullscreen="on" data-bsp-nav="collapsed">
|
|
47
|
+
|
|
48
|
+
One stylesheet reads them. No `MutationObserver`, no re-render on the host's
|
|
49
|
+
React tree, no writes to any host store. The host may mount, unmount and
|
|
50
|
+
remount the settings panel at will; the state is already on the root element.
|
|
51
|
+
|
|
52
|
+
Three things about the shipped DOM are worth knowing before you write a similar
|
|
53
|
+
patch.
|
|
54
|
+
|
|
55
|
+
**1. `[data-slot="…"]` is a renderer anchor, not a container.** The renderer's
|
|
56
|
+
`SlotOutlet` emits it with `style: { display: "contents" }`, so the element
|
|
57
|
+
generates no box: its children take part in the *grandparent's* layout. It also
|
|
58
|
+
sits inside `.actions` / `.options`, never directly under `.header`. So:
|
|
59
|
+
|
|
60
|
+
/* correct: match the header row by descendant */
|
|
61
|
+
div[data-shortcut-modal="settings"] > div:has([data-slot="settings.action"])
|
|
62
|
+
/* wrong, silently matches .actions: */
|
|
63
|
+
div[data-shortcut-modal="settings"] > div:has(> [data-slot="settings.action"])
|
|
64
|
+
|
|
65
|
+
**2. The mask owns click-outside-to-close.** `div.mask` carries `onClick={onClose}`.
|
|
66
|
+
`display: none` or `pointer-events: none` on it does not "clean up the overlay",
|
|
67
|
+
it deletes a feature with no error. This plugin only strips the mask's *paint*
|
|
68
|
+
(`background: transparent; backdrop-filter: none`) and leaves its box alone.
|
|
69
|
+
|
|
70
|
+
**3. The grid area names are CSS-Modules hashes.** Override only the
|
|
71
|
+
`grid-template-columns` longhand. Rewriting the `grid-template` shorthand would
|
|
72
|
+
also reset `"title header" / "nav options"` and collapse the layout.
|
|
73
|
+
|
|
74
|
+
There is no `!important` and no host class name in the stylesheet: every rule is
|
|
75
|
+
anchored on `div[data-shortcut-modal="settings"]`, which is a stable data
|
|
76
|
+
attribute and already more specific (0,1,1) than the shipped module classes
|
|
77
|
+
(0,1,0).
|
|
78
|
+
|
|
79
|
+
## Switches
|
|
80
|
+
|
|
81
|
+
| Field | Default | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `fullscreen` | `true` | Fill the window instead of 800px. |
|
|
84
|
+
| `navCollapsed` | `false` | Collapse the section rail to icons. |
|
|
85
|
+
|
|
86
|
+
Stored shape:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{ "version": 1, "fullscreen": true, "navCollapsed": false, "unknown": {} }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Keys this version does not recognise are carried in `unknown` and written back,
|
|
93
|
+
so a downgrade or a newer build does not lose data. A malformed record falls
|
|
94
|
+
back to defaults instead of throwing.
|
|
95
|
+
|
|
96
|
+
## Limits
|
|
97
|
+
|
|
98
|
+
- **Not a full-screen window.** `html[data-fullscreen]` is the *window's* state,
|
|
99
|
+
not this plugin's; forcing it would also change where the host anchors its
|
|
100
|
+
menus. This page fills the window that exists.
|
|
101
|
+
- **Desktop only for the drag strip — and not yet verified there.** On the web
|
|
102
|
+
runtime there is no title bar (`--dsh-frame-top-clearance` is unset), so the
|
|
103
|
+
computed padding is `max(0px, 0px)`: the plugin adds no strip and no drag
|
|
104
|
+
region, which means the one thing this release cannot demonstrate is the thing
|
|
105
|
+
the strip exists for. Until a real Desktop app run says otherwise, treat the
|
|
106
|
+
40px drag area as **unverified**.
|
|
107
|
+
- **Narrow windows keep the host layout.** Below 768px the harness turns the
|
|
108
|
+
panel into a full-screen sheet on its own; collapsing to 56px there would
|
|
109
|
+
leave nothing, so the collapse rule is behind `@media (min-width: 768px)`.
|
|
110
|
+
- **Settings are per origin.** A different port is a different profile: your two
|
|
111
|
+
switches reset there.
|
|
112
|
+
|
|
113
|
+
## Files
|
|
114
|
+
|
|
115
|
+
client.js browser half: stylesheet, rail toggle, settings page
|
|
116
|
+
index.js host half: exists so the bundle resolves, injects nothing
|
|
117
|
+
cordis.patch.yml bundle patch
|
|
118
|
+
test/settings-page.test.mjs 30 checks, no dependencies
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
node --check client.js
|
|
123
|
+
node --check index.js
|
|
124
|
+
node test/settings-page.test.mjs # → PASS — 30 groups
|
|
125
|
+
|
|
126
|
+
The test executes the real `client.js` with a stubbed `window.__ModuleLoader__`
|
|
127
|
+
and asserts on the exported strings and store, including two regression traps:
|
|
128
|
+
one fails if the header selector is ever "simplified" back to a direct child,
|
|
129
|
+
the other fails if the full-screen rules stop being gated on this plugin's own
|
|
130
|
+
attribute. Whether the page really fills the window is a machine check, not a
|
|
131
|
+
unit test.
|
|
132
|
+
|
|
133
|
+
On the web runtime (Harness `0.2.2-rc.1`, 1600×950) that machine check was run
|
|
134
|
+
and three of four items passed: the panel rect equals the viewport
|
|
135
|
+
(`{0,0,1600,950}`), the rail goes `188px 1412px` → `56px 1544px` with
|
|
136
|
+
`aria-expanded="false"`, and the choice survives a reload while `Esc` and the
|
|
137
|
+
close button still close. The fourth — the desktop 40px title-bar drag strip —
|
|
138
|
+
**was not verified**: see [Limits](#limits).
|
|
139
|
+
|
|
18
140
|
## License
|
|
19
141
|
|
|
20
|
-
MIT
|
|
142
|
+
MIT
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Better Setting Page · 全屏设置页
|
|
2
|
+
|
|
3
|
+
把 DeepSeek Harness 的设置面板变成**铺满整个窗口的页面**,并让左侧分类栏**收成
|
|
4
|
+
56px 的图标列**。
|
|
5
|
+
|
|
6
|
+
这个宿主的设置默认是 800px 宽的弹窗。三五行开关够用,再多就挤:一页「每类一张卡」
|
|
7
|
+
的设置在这个宽度里根本读不下去,而屏幕上剩下的大片空白是用户的。这个插件不增加
|
|
8
|
+
任何设置项,只是把设置页本该有的地方还给它。
|
|
9
|
+
|
|
10
|
+
对照版本:宿主 `0.2.2-rc.1`(解包 asar 里的 `@deepseek-ai/dsh-client-ui-settings-general`
|
|
11
|
+
0.2.2-rc.1)。下面[「它怎么做到的」](#它怎么做到的)写清了每一处锚点,宿主改版后你可以
|
|
12
|
+
照着判断还对不对得上。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
dsh plugin --profile <profile> add dsh-better-setting-page
|
|
17
|
+
|
|
18
|
+
装完要重启那个 profile 的窗口:浏览器缓存着旧的 bundle。
|
|
19
|
+
|
|
20
|
+
## 你会得到什么
|
|
21
|
+
|
|
22
|
+
| | |
|
|
23
|
+
|---|---|
|
|
24
|
+
| 铺满窗口 | 面板占满整个窗口,圆角与投影去掉。桌面版把标题栏高度留成上边距,那一条仍然能拖动窗口。 |
|
|
25
|
+
| 可收起的分类栏 | 页头多一个箭头,一点就把分类栏收成 56px 宽的图标列。分类名留在无障碍树里,读屏和 `Tab` 仍然念得出来。 |
|
|
26
|
+
| 记在这个浏览器里 | 两个开关存在本机浏览器 profile 的 `localStorage`,键 `dsh.better-setting-page.settings`。 |
|
|
27
|
+
| 自己的一页设置 | **设置 → 设置页** 里有这两个开关和**恢复默认**。没有这一页,讨厌全屏的用户除了卸载插件没别的办法。 |
|
|
28
|
+
|
|
29
|
+
全屏**默认开**,分类栏**默认展开**。两个都能切,切完重启仍然记得。
|
|
30
|
+
|
|
31
|
+
关闭方式和以前完全一样:`Esc`、关闭按钮、点遮罩。遮罩还在、还能点,只是全屏时它被
|
|
32
|
+
面板盖住了,实际上你会用 `Esc` 或关闭按钮。
|
|
33
|
+
|
|
34
|
+
分类栏收起是纯 CSS:分类名只是视觉隐藏,没有从文档里删,所以 `Tab` 仍然逐个聚焦、读屏
|
|
35
|
+
仍然念得出来。没有「悬停临时展开」:只有那个箭头会切换状态,别的都不动。
|
|
36
|
+
|
|
37
|
+
## 它怎么做到的
|
|
38
|
+
|
|
39
|
+
全部状态是这个插件挂在 `<html>` 上的两个属性:
|
|
40
|
+
|
|
41
|
+
<html data-bsp-fullscreen="on" data-bsp-nav="collapsed">
|
|
42
|
+
|
|
43
|
+
一份样式表读它们。没有 `MutationObserver`,不碰宿主 React 树的重渲染,不写宿主的
|
|
44
|
+
任何 store。宿主随时可以挂载、卸载、再挂载设置面板,状态早就在根元素上了。
|
|
45
|
+
|
|
46
|
+
写类似补丁之前,关于宿主 DOM 的三件事值得先知道。
|
|
47
|
+
|
|
48
|
+
**1. `[data-slot="…"]` 是渲染器的锚点,不是容器。** 渲染器的 `SlotOutlet` 把它输出成
|
|
49
|
+
`style: { display: "contents" }`,所以这个元素不产生盒子:它的子元素直接参与**祖父
|
|
50
|
+
层**的布局。而且它挂在 `.actions` / `.options` 里面,从来不是 `.header` 的直接子级。
|
|
51
|
+
所以:
|
|
52
|
+
|
|
53
|
+
/* 对:用后代匹配找到页头所在的那一行 */
|
|
54
|
+
div[data-shortcut-modal="settings"] > div:has([data-slot="settings.action"])
|
|
55
|
+
/* 错:会静默匹配到 .actions */
|
|
56
|
+
div[data-shortcut-modal="settings"] > div:has(> [data-slot="settings.action"])
|
|
57
|
+
|
|
58
|
+
**2. 遮罩上挂着「点外面关闭」。** `div.mask` 带 `onClick={onClose}`。给它
|
|
59
|
+
`display: none` 或 `pointer-events: none` 不是「顺手清干净浮层」,而是无声无息删掉
|
|
60
|
+
一个功能。这个插件只摘掉遮罩的**外观**(`background: transparent;
|
|
61
|
+
backdrop-filter: none`),盒子和命中区原样留着。
|
|
62
|
+
|
|
63
|
+
**3. 网格区域名是 CSS-Modules 的哈希。** 只覆盖 `grid-template-columns` 这个长写属性。
|
|
64
|
+
重写 `grid-template` 简写会连 `"title header" / "nav options"` 一起重置,布局直接塌。
|
|
65
|
+
|
|
66
|
+
样式表里没有 `!important`,也没有任何宿主类名:每条规则都锚在
|
|
67
|
+
`div[data-shortcut-modal="settings"]` 这个稳定的 data 属性上,特异性 (0,1,1) 本来就
|
|
68
|
+
高于宿主的模块类 (0,1,0)。
|
|
69
|
+
|
|
70
|
+
## 开关
|
|
71
|
+
|
|
72
|
+
| 字段 | 默认 | 含义 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `fullscreen` | `true` | 铺满窗口,而不是 800px。 |
|
|
75
|
+
| `navCollapsed` | `false` | 把分类栏收成图标列。 |
|
|
76
|
+
|
|
77
|
+
存储形状:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{ "version": 1, "fullscreen": true, "navCollapsed": false, "unknown": {} }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
这个版本不认识的键会放进 `unknown` 原样写回,降级或更高版本都不会丢数据。记录坏掉
|
|
84
|
+
时回落默认值,不抛错。
|
|
85
|
+
|
|
86
|
+
## 限制
|
|
87
|
+
|
|
88
|
+
- **这不是「窗口全屏」。** `html[data-fullscreen]` 是**窗口**的状态,不是这个插件的;
|
|
89
|
+
硬写它连宿主菜单的锚定位置都会一起变。这里只让设置页填满已有的窗口。
|
|
90
|
+
- **拖拽带只在桌面版有,而且还没在桌面版验过。** 网页运行时没有标题栏
|
|
91
|
+
(`--dsh-frame-top-clearance` 无值),于是补白算出来是 `max(0px, 0px)`:插件既不补
|
|
92
|
+
上边距、也不加拖拽区 —— 也就是说,这条拖拽带存在的理由,恰好是这一版没法证明的
|
|
93
|
+
东西。在真机桌面版跑过之前,那 40px 拖拽区请当作**未验证**。
|
|
94
|
+
- **窄窗口保持宿主布局。** 768px 以下宿主自己就把面板变成全屏 sheet;这时候再收成
|
|
95
|
+
56px 会什么都不剩,因此收起规则放在 `@media (min-width: 768px)` 里。
|
|
96
|
+
- **设置按 origin 隔离。** 换个端口就是另一个 profile,两个开关会回到默认。
|
|
97
|
+
|
|
98
|
+
## 文件
|
|
99
|
+
|
|
100
|
+
client.js 浏览器半边:样式表、分类栏开关、设置页
|
|
101
|
+
index.js host 半边:只为让 bundle 解析得到,不注入任何东西
|
|
102
|
+
cordis.patch.yml bundle patch
|
|
103
|
+
test/settings-page.test.mjs 30 条断言,零依赖
|
|
104
|
+
|
|
105
|
+
## 测试
|
|
106
|
+
|
|
107
|
+
node --check client.js
|
|
108
|
+
node --check index.js
|
|
109
|
+
node test/settings-page.test.mjs # → PASS — 30 groups
|
|
110
|
+
|
|
111
|
+
测试用桩掉的 `window.__ModuleLoader__` 执行真的 `client.js`,然后对导出的字符串和
|
|
112
|
+
store 下断言,其中埋了两条**回归陷阱**:一条在页头选择器被「顺手简化」回直接子级时
|
|
113
|
+
失败,另一条在全屏规则不再挂在本插件自己的属性上时失败。页面是不是真的铺满窗口属于
|
|
114
|
+
机器验证,不是单测能证明的事。
|
|
115
|
+
|
|
116
|
+
那次机器验证在网页运行时跑过了(Harness `0.2.2-rc.1`,1600×950),四项里过了三项:
|
|
117
|
+
面板 rect 等于视口(`{0,0,1600,950}`)、左栏 `188px 1412px` → `56px 1544px` 且
|
|
118
|
+
`aria-expanded="false"`、刷新后选择仍在且 `Esc` 与关闭键仍能关。第四项 —— 桌面版
|
|
119
|
+
40px 标题带的拖拽区 —— **没有验过**,见[限制](#限制)。
|
|
120
|
+
|
|
121
|
+
## 许可
|
|
122
|
+
|
|
123
|
+
MIT
|
package/client.js
CHANGED
|
@@ -1,17 +1,701 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser half of `dsh-better-setting-page` —
|
|
2
|
+
* Browser half of `dsh-better-setting-page` — full-screen settings page with a
|
|
3
|
+
* collapsible navigation rail.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* stylesheet
|
|
6
|
-
*
|
|
5
|
+
* What it does (and nothing else):
|
|
6
|
+
* 1. One stylesheet that turns the shipped settings dialog into a full-window
|
|
7
|
+
* page and narrows its left rail to 56px when collapsed. Every rule hangs
|
|
8
|
+
* off two attributes we own on `<html>`, so the panel can be unmounted and
|
|
9
|
+
* remounted by the host at any time and the state survives without any
|
|
10
|
+
* MutationObserver.
|
|
11
|
+
* 2. One button in the `settings.action` slot (the content-column header, left
|
|
12
|
+
* of the shipped "document" button) that toggles the rail.
|
|
13
|
+
* 3. One `settings.section` page with the two switches and "restore defaults",
|
|
14
|
+
* because a plugin that can only be undone by clearing localStorage is a
|
|
15
|
+
* plugin the user cannot turn off.
|
|
16
|
+
*
|
|
17
|
+
* Deliberate non-goals: no `settings.header` takeover (that slot is the dialog's
|
|
18
|
+
* `aria-labelledby` target and shadows shipped UI), no `display:none` on the
|
|
19
|
+
* mask (its `onClick` is the click-outside-to-close handler), no `!important`,
|
|
20
|
+
* no hash class names, no host store writes.
|
|
21
|
+
*
|
|
22
|
+
* Selector notes that are easy to get wrong (verified against the shipped DOM):
|
|
23
|
+
* - `[data-slot="…"]` is emitted by the renderer's `SlotOutlet` with
|
|
24
|
+
* `style:{display:"contents"}`. It produces no box, so it can be used to
|
|
25
|
+
* anchor a selector but never as a sized container.
|
|
26
|
+
* - It sits inside `.actions` / `.options`, never directly under `.header`.
|
|
27
|
+
* Matching the header row therefore has to use a descendant combinator:
|
|
28
|
+
* `> div:has([data-slot="settings.action"])`.
|
|
29
|
+
* - `clip` alone does not hide anything: the visually-hidden utility below
|
|
30
|
+
* needs absolute positioning plus `clip-path`.
|
|
7
31
|
*/
|
|
32
|
+
|
|
8
33
|
window.__ModuleLoader__.load({
|
|
9
34
|
id: 'dsh-better-setting-page',
|
|
10
|
-
factory() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
35
|
+
factory(require) {
|
|
36
|
+
const React = require('react')
|
|
37
|
+
const h = React.createElement
|
|
38
|
+
|
|
39
|
+
/** Locale namespace and the prefix of every DOM attribute we own. */
|
|
40
|
+
const NS = 'better-setting-page'
|
|
41
|
+
const VERSION = '0.1.0'
|
|
42
|
+
|
|
43
|
+
/** localStorage key. Bumping `SETTINGS_VERSION` requires a migration. */
|
|
44
|
+
const STORE_KEY = 'dsh.better-setting-page.settings'
|
|
45
|
+
const SETTINGS_VERSION = 1
|
|
46
|
+
|
|
47
|
+
const DEFAULTS = {
|
|
48
|
+
version: SETTINGS_VERSION,
|
|
49
|
+
fullscreen: true,
|
|
50
|
+
navCollapsed: false,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `<html>` attributes: the only state the stylesheet reads. */
|
|
54
|
+
const FULLSCREEN_ATTR = 'data-bsp-fullscreen'
|
|
55
|
+
const NAV_ATTR = 'data-bsp-nav'
|
|
56
|
+
/** Id we lend to the host's `<nav>` so `aria-controls` has a target. */
|
|
57
|
+
const NAV_ID = 'dsh-bsp-nav'
|
|
58
|
+
|
|
59
|
+
const PANEL = 'div[data-shortcut-modal="settings"]'
|
|
60
|
+
|
|
61
|
+
const SECTION_ID = 'better-setting-page'
|
|
62
|
+
const SECTION_ORDER = 50
|
|
63
|
+
const ACTION_ID = 'better-setting-page.rail-toggle'
|
|
64
|
+
|
|
65
|
+
//#region stylesheet
|
|
66
|
+
/**
|
|
67
|
+
* The whole visual layer. No `!important` anywhere: `div[data-shortcut-modal
|
|
68
|
+
* ="settings"]` is (0,1,1) against the shipped module classes at (0,1,0), and
|
|
69
|
+
* every rule below is at least that specific.
|
|
70
|
+
*/
|
|
71
|
+
const CSS = `
|
|
72
|
+
/* ── 1. Full-screen panel ───────────────────────────────────────────────── */
|
|
73
|
+
html[data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"]{
|
|
74
|
+
--bsp-top-pad: max(var(--dsh-windows-titlebar-height, 0px), var(--dsh-frame-top-clearance, 0px));
|
|
75
|
+
box-sizing: border-box;
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
max-width: none;
|
|
79
|
+
max-height: none;
|
|
80
|
+
border-radius: 0;
|
|
81
|
+
box-shadow: none;
|
|
82
|
+
padding-top: var(--bsp-top-pad);
|
|
83
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* A genuinely full-screen window has no title bar, so there is nothing to pad. */
|
|
87
|
+
html[data-bsp-fullscreen="on"][data-fullscreen] div[data-shortcut-modal="settings"]{
|
|
88
|
+
--bsp-top-pad: 0px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* The pad strip is window chrome, not page content: keep it draggable, but only
|
|
92
|
+
on desktop, where that strip actually exists. */
|
|
93
|
+
html[data-windows-titlebar][data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"]::before,
|
|
94
|
+
html[data-platform="darwin"][data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"]::before{
|
|
95
|
+
content: "";
|
|
96
|
+
position: absolute;
|
|
97
|
+
inset: 0 0 auto 0;
|
|
98
|
+
height: var(--bsp-top-pad);
|
|
99
|
+
-webkit-app-region: drag;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── 2. Mask keeps its hit area, loses only its paint ───────────────────── */
|
|
103
|
+
/* The mask owns the click-outside-to-close handler. Hiding it or switching off
|
|
104
|
+
its pointer events does not "clean up the overlay", it deletes a feature with
|
|
105
|
+
no error. Only the paint is removed here; the box stays. */
|
|
106
|
+
html[data-bsp-fullscreen="on"] div:has(> div[data-shortcut-modal="settings"]) > div:first-child{
|
|
107
|
+
background: transparent;
|
|
108
|
+
backdrop-filter: none;
|
|
109
|
+
-webkit-backdrop-filter: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ── 3. Content column ──────────────────────────────────────────────────── */
|
|
113
|
+
/* Header row: the action slot lives inside .actions, so match by descendant. */
|
|
114
|
+
html[data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"] > div:has([data-slot="settings.action"]){
|
|
115
|
+
padding-right: 26px;
|
|
116
|
+
}
|
|
117
|
+
/* Content column is the direct parent of the section outlet. */
|
|
118
|
+
html[data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"] > div:has(> [data-slot="settings.section"]){
|
|
119
|
+
min-height: 0;
|
|
120
|
+
}
|
|
121
|
+
/* Wide windows get breathing room; narrow ones keep the host's tighter padding. */
|
|
122
|
+
@media (min-width: 768px){
|
|
123
|
+
html[data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"] > div:has(> [data-slot="settings.section"]){
|
|
124
|
+
padding: 0 40px 40px;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/* A full-width page would otherwise run a line across the whole monitor. */
|
|
128
|
+
html[data-bsp-fullscreen="on"] div[data-shortcut-modal="settings"] [data-slot="settings.section"] > *{
|
|
129
|
+
max-width: 880px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── 4. Collapsible rail ────────────────────────────────────────────────── */
|
|
133
|
+
/* Everything about collapsing is behind the same breakpoint the host uses to
|
|
134
|
+
turn the rail into a horizontal tab bar (<=767px). Collapsing a horizontal bar
|
|
135
|
+
has no meaning, and hiding its labels there would leave an unlabelled strip. */
|
|
136
|
+
@media (min-width: 768px){
|
|
137
|
+
/* Only the grid-template-columns longhand: the area names in the shorthand are
|
|
138
|
+
CSS-Modules hashes and would break on the next build. */
|
|
139
|
+
html[data-bsp-nav="collapsed"] div[data-shortcut-modal="settings"]{
|
|
140
|
+
grid-template-columns: 56px minmax(0, 1fr);
|
|
141
|
+
}
|
|
142
|
+
html[data-bsp-nav="collapsed"] div[data-shortcut-modal="settings"] > nav{
|
|
143
|
+
padding: 10px 8px 0;
|
|
144
|
+
}
|
|
145
|
+
html[data-bsp-nav="collapsed"] div[data-shortcut-modal="settings"] > nav > div > button{
|
|
146
|
+
/* Anchors the visually-hidden label to its own cell instead of the panel. */
|
|
147
|
+
position: relative;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
padding: 9px 0;
|
|
150
|
+
gap: 0;
|
|
151
|
+
}
|
|
152
|
+
/* The panel title stays readable, it just loses its 24px indent. Hiding it
|
|
153
|
+
would also hide the dialog's accessible name, which is this node. */
|
|
154
|
+
html[data-bsp-nav="collapsed"] div[data-shortcut-modal="settings"] > div[id]{
|
|
155
|
+
padding: 22px 4px 8px;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
font-size: 13px;
|
|
158
|
+
line-height: 24px;
|
|
159
|
+
text-align: center;
|
|
160
|
+
text-overflow: ellipsis;
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
}
|
|
163
|
+
/* Visually hidden, still in the accessibility tree: with the label gone the
|
|
164
|
+
icon is all that is left, and the icon carries no accessible name of its
|
|
165
|
+
own. Second span rather than last-child, so that a cell rendered without an
|
|
166
|
+
icon span never causes the icon itself to be hidden. */
|
|
167
|
+
html[data-bsp-nav="collapsed"] div[data-shortcut-modal="settings"] > nav > div > button > span:nth-child(2){
|
|
168
|
+
position: absolute;
|
|
169
|
+
width: 1px;
|
|
170
|
+
height: 1px;
|
|
171
|
+
margin: -1px;
|
|
172
|
+
padding: 0;
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
clip-path: inset(50%);
|
|
175
|
+
white-space: nowrap;
|
|
176
|
+
border: 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ── 5. Rail toggle button (settings.action) ────────────────────────────── */
|
|
181
|
+
[data-slot="settings.action"] > .bsp-rail-toggle{
|
|
182
|
+
box-sizing: border-box;
|
|
183
|
+
display: inline-flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
justify-content: center;
|
|
186
|
+
width: 28px;
|
|
187
|
+
height: 28px;
|
|
188
|
+
padding: 0;
|
|
189
|
+
border: 0;
|
|
190
|
+
border-radius: var(--dsw-radius-sm);
|
|
191
|
+
background: transparent;
|
|
192
|
+
color: var(--dsw-alias-label-primary);
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
}
|
|
195
|
+
[data-slot="settings.action"] > .bsp-rail-toggle:hover{
|
|
196
|
+
background-color: var(--dsw-alias-interactive-bg-hover);
|
|
197
|
+
}
|
|
198
|
+
[data-slot="settings.action"] > .bsp-rail-toggle:focus-visible{
|
|
199
|
+
outline: var(--dsw-focus-ring-width, 2px) solid
|
|
200
|
+
var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
201
|
+
outline-offset: -2px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── 6. Our own settings page ───────────────────────────────────────────── */
|
|
205
|
+
.bsp-page{
|
|
206
|
+
display: flex;
|
|
207
|
+
flex-direction: column;
|
|
208
|
+
gap: 4px;
|
|
209
|
+
max-width: 880px;
|
|
210
|
+
padding: 8px 2px 28px;
|
|
211
|
+
}
|
|
212
|
+
.bsp-head{ padding: 4px 0 12px; }
|
|
213
|
+
.bsp-title{
|
|
214
|
+
margin: 0 0 6px;
|
|
215
|
+
font-size: 16px;
|
|
216
|
+
font-weight: 650;
|
|
217
|
+
letter-spacing: -0.01em;
|
|
218
|
+
color: var(--dsw-alias-label-primary);
|
|
219
|
+
}
|
|
220
|
+
.bsp-sub{
|
|
221
|
+
margin: 0;
|
|
222
|
+
max-width: 64ch;
|
|
223
|
+
font-size: 12px;
|
|
224
|
+
line-height: 1.55;
|
|
225
|
+
color: var(--dsw-alias-label-tertiary);
|
|
226
|
+
}
|
|
227
|
+
.bsp-row{
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
justify-content: space-between;
|
|
231
|
+
gap: 24px;
|
|
232
|
+
padding: 16px 0;
|
|
233
|
+
border-bottom: .5px solid var(--dsw-alias-border-l2);
|
|
234
|
+
}
|
|
235
|
+
.bsp-rowText{ min-width: 0; }
|
|
236
|
+
.bsp-rowLabel{
|
|
237
|
+
font-size: 14px;
|
|
238
|
+
line-height: 20px;
|
|
239
|
+
color: var(--dsw-alias-label-primary);
|
|
240
|
+
}
|
|
241
|
+
.bsp-rowHint{
|
|
242
|
+
margin-top: 4px;
|
|
243
|
+
font-size: 12px;
|
|
244
|
+
line-height: 18px;
|
|
245
|
+
color: var(--dsw-alias-label-secondary);
|
|
246
|
+
}
|
|
247
|
+
.bsp-check{
|
|
248
|
+
flex: none;
|
|
249
|
+
width: 16px;
|
|
250
|
+
height: 16px;
|
|
251
|
+
accent-color: var(--dsw-alias-brand-primary);
|
|
252
|
+
cursor: pointer;
|
|
253
|
+
}
|
|
254
|
+
.bsp-actions{
|
|
255
|
+
display: flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
gap: 12px;
|
|
258
|
+
padding: 16px 0 4px;
|
|
259
|
+
}
|
|
260
|
+
.bsp-button{
|
|
261
|
+
min-height: 32px;
|
|
262
|
+
padding: 0 14px;
|
|
263
|
+
border: .5px solid var(--dsw-alias-border-l4);
|
|
264
|
+
border-radius: var(--dsw-radius-md);
|
|
265
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
266
|
+
color: var(--dsw-alias-label-primary);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
}
|
|
270
|
+
.bsp-button:hover{ background-color: var(--dsw-alias-interactive-bg-hover); }
|
|
271
|
+
.bsp-button:focus-visible{
|
|
272
|
+
outline: var(--dsw-focus-ring-width, 2px) solid
|
|
273
|
+
var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
|
274
|
+
outline-offset: 1px;
|
|
275
|
+
}
|
|
276
|
+
.bsp-notice{
|
|
277
|
+
font-size: 12px;
|
|
278
|
+
line-height: 18px;
|
|
279
|
+
color: var(--dsw-alias-label-tertiary);
|
|
280
|
+
}
|
|
281
|
+
.bsp-note{
|
|
282
|
+
margin: 12px 0 0;
|
|
283
|
+
max-width: 64ch;
|
|
284
|
+
font-size: 12px;
|
|
285
|
+
line-height: 1.55;
|
|
286
|
+
color: var(--dsw-alias-label-tertiary);
|
|
287
|
+
}
|
|
288
|
+
`.trim()
|
|
289
|
+
//#endregion
|
|
290
|
+
|
|
291
|
+
//#region dictionaries
|
|
292
|
+
const ZH = {
|
|
293
|
+
'section.nav': '设置页',
|
|
294
|
+
'rail.expand': '展开分类栏',
|
|
295
|
+
'rail.collapse': '收起分类栏',
|
|
296
|
+
'page.title': '设置页外观',
|
|
297
|
+
'page.intro': '这个插件只改设置页的呈现方式,不读也不写任何设置值。',
|
|
298
|
+
'page.fullscreen': '全屏设置页',
|
|
299
|
+
'page.fullscreenHint': '铺满整个窗口。桌面版会在顶部留出标题栏高度,那一条仍然可以拖动窗口。',
|
|
300
|
+
'page.navCollapsed': '默认收起左侧分类栏',
|
|
301
|
+
'page.navCollapsedHint': '收成 56px 图标栏,分类名保留给读屏。点右上角箭头可随时切换。',
|
|
302
|
+
'page.reset': '恢复默认',
|
|
303
|
+
'page.resetDone': '已恢复默认',
|
|
304
|
+
'page.saveFailed': '浏览器拒绝保存(localStorage 已满或被禁用),这次改动没有生效。',
|
|
305
|
+
'page.note': '这两项存在本机浏览器的 localStorage(键 dsh.better-setting-page.settings),换端口或清站点数据会丢。不认识的字段会原样保留。',
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const EN = {
|
|
309
|
+
'section.nav': 'Settings page',
|
|
310
|
+
'rail.expand': 'Expand section list',
|
|
311
|
+
'rail.collapse': 'Collapse section list',
|
|
312
|
+
'page.title': 'Settings page appearance',
|
|
313
|
+
'page.intro': 'This plugin only changes how the settings page is drawn. It reads and writes no setting of its own.',
|
|
314
|
+
'page.fullscreen': 'Full-screen settings',
|
|
315
|
+
'page.fullscreenHint': 'Fill the whole window. On desktop the title-bar height is kept as padding, and that strip still drags the window.',
|
|
316
|
+
'page.navCollapsed': 'Collapse the section list by default',
|
|
317
|
+
'page.navCollapsedHint': 'Shrinks the rail to a 56px icon column; section names stay in the accessibility tree. Toggle it any time with the arrow in the header.',
|
|
318
|
+
'page.reset': 'Restore defaults',
|
|
319
|
+
'page.resetDone': 'Defaults restored',
|
|
320
|
+
'page.saveFailed': 'The browser refused to save this (localStorage full or blocked); the change did not stick.',
|
|
321
|
+
'page.note': 'Both switches live in this browser profile’s localStorage (key dsh.better-setting-page.settings), so a different port or cleared site data resets them. Unknown fields are preserved as-is.',
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
|
|
325
|
+
//#region settings store
|
|
326
|
+
function isPlainObject(value) {
|
|
327
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Turn whatever was on disk into the exact shape this version understands.
|
|
332
|
+
* Unknown top-level keys are carried in `unknown` so a downgrade or a future
|
|
333
|
+
* version does not lose data the user (or a newer build) wrote.
|
|
334
|
+
*/
|
|
335
|
+
function sanitizeSettings(raw) {
|
|
336
|
+
const next = { version: SETTINGS_VERSION }
|
|
337
|
+
const unknown = isPlainObject(raw) && isPlainObject(raw.unknown) ? { ...raw.unknown } : {}
|
|
338
|
+
if (isPlainObject(raw)) {
|
|
339
|
+
for (const key of Object.keys(raw)) {
|
|
340
|
+
if (key === 'unknown' || key === 'version' || key in DEFAULTS) continue
|
|
341
|
+
unknown[key] = raw[key]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
next.fullscreen = isPlainObject(raw) && typeof raw.fullscreen === 'boolean'
|
|
345
|
+
? raw.fullscreen
|
|
346
|
+
: DEFAULTS.fullscreen
|
|
347
|
+
next.navCollapsed = isPlainObject(raw) && typeof raw.navCollapsed === 'boolean'
|
|
348
|
+
? raw.navCollapsed
|
|
349
|
+
: DEFAULTS.navCollapsed
|
|
350
|
+
next.unknown = unknown
|
|
351
|
+
return next
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Last write/read problem, surfaced on the settings page. */
|
|
355
|
+
let lastError = null
|
|
356
|
+
|
|
357
|
+
function readSettings() {
|
|
358
|
+
let raw = null
|
|
359
|
+
try {
|
|
360
|
+
const text = window.localStorage.getItem(STORE_KEY)
|
|
361
|
+
if (typeof text === 'string' && text !== '') raw = JSON.parse(text)
|
|
362
|
+
lastError = null
|
|
363
|
+
} catch (error) {
|
|
364
|
+
lastError = 'read'
|
|
365
|
+
raw = null
|
|
366
|
+
}
|
|
367
|
+
return sanitizeSettings(raw)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function persist(settings) {
|
|
371
|
+
try {
|
|
372
|
+
window.localStorage.setItem(STORE_KEY, JSON.stringify(settings))
|
|
373
|
+
lastError = null
|
|
374
|
+
} catch (error) {
|
|
375
|
+
lastError = 'write'
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let cache = null
|
|
380
|
+
const listeners = new Set()
|
|
381
|
+
|
|
382
|
+
function currentSettings() {
|
|
383
|
+
if (cache === null) cache = readSettings()
|
|
384
|
+
return cache
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Push a sanitized snapshot everywhere: disk, `<html>` attributes, listeners.
|
|
389
|
+
* The `storage` event does not fire in the document that wrote it, so we
|
|
390
|
+
* notify our own subscribers here instead of waiting for the event.
|
|
391
|
+
*/
|
|
392
|
+
function commit(next) {
|
|
393
|
+
cache = next
|
|
394
|
+
persist(next)
|
|
395
|
+
applyToDocument(next)
|
|
396
|
+
for (const fn of listeners) fn(next)
|
|
397
|
+
return next
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function writeSettings(patch) {
|
|
401
|
+
// Base the merge on what is on disk, not on the in-memory cache: another
|
|
402
|
+
// window may have written unknown keys that this one has never seen, and
|
|
403
|
+
// a patch must not drop them.
|
|
404
|
+
const base = readSettings()
|
|
405
|
+
const merged = isPlainObject(patch) ? { ...base, ...patch } : base
|
|
406
|
+
return commit(sanitizeSettings(merged))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function resetSettings() {
|
|
410
|
+
return writeSettings({
|
|
411
|
+
fullscreen: DEFAULTS.fullscreen,
|
|
412
|
+
navCollapsed: DEFAULTS.navCollapsed,
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function subscribeSettings(listener) {
|
|
417
|
+
listeners.add(listener)
|
|
418
|
+
return () => listeners.delete(listener)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The only DOM this plugin writes outside its own subtree. */
|
|
422
|
+
function applyToDocument(settings) {
|
|
423
|
+
if (typeof document === 'undefined') return
|
|
424
|
+
const root = document.documentElement
|
|
425
|
+
if (root === null || root === undefined) return
|
|
426
|
+
root.setAttribute(FULLSCREEN_ATTR, settings.fullscreen === true ? 'on' : 'off')
|
|
427
|
+
root.setAttribute(NAV_ATTR, settings.navCollapsed === true ? 'collapsed' : 'expanded')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function clearDocument() {
|
|
431
|
+
if (typeof document === 'undefined') return
|
|
432
|
+
const root = document.documentElement
|
|
433
|
+
if (root === null || root === undefined) return
|
|
434
|
+
root.removeAttribute(FULLSCREEN_ATTR)
|
|
435
|
+
root.removeAttribute(NAV_ATTR)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Another window sharing this profile changed the settings. */
|
|
439
|
+
function watchStorage() {
|
|
440
|
+
const handler = (event) => {
|
|
441
|
+
if (event.key !== null && event.key !== STORE_KEY) return
|
|
442
|
+
const next = readSettings()
|
|
443
|
+
cache = next
|
|
444
|
+
applyToDocument(next)
|
|
445
|
+
for (const fn of listeners) fn(next)
|
|
446
|
+
}
|
|
447
|
+
window.addEventListener('storage', handler)
|
|
448
|
+
return () => window.removeEventListener('storage', handler)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function useSettings() {
|
|
452
|
+
const [value, setValue] = React.useState(currentSettings)
|
|
453
|
+
React.useEffect(() => {
|
|
454
|
+
// A write can land between render and effect; resync once, then subscribe.
|
|
455
|
+
setValue(currentSettings())
|
|
456
|
+
return subscribeSettings(setValue)
|
|
457
|
+
}, [])
|
|
458
|
+
return value
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
|
|
462
|
+
//#region components
|
|
463
|
+
function translateOf(props) {
|
|
464
|
+
return typeof props.t === 'function' ? props.t : (key) => key
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Chevron, 14px, drawn by hand so a missing primitives module cannot break us. */
|
|
468
|
+
function Chevron(props) {
|
|
469
|
+
const pointsRight = props.direction === 'right'
|
|
470
|
+
return h('svg', {
|
|
471
|
+
width: 14,
|
|
472
|
+
height: 14,
|
|
473
|
+
viewBox: '0 0 16 16',
|
|
474
|
+
fill: 'none',
|
|
475
|
+
'aria-hidden': 'true',
|
|
476
|
+
focusable: 'false',
|
|
477
|
+
}, h('path', {
|
|
478
|
+
d: pointsRight ? 'M6.25 3.5 10.75 8l-4.5 4.5' : 'M9.75 3.5 5.25 8l4.5 4.5',
|
|
479
|
+
stroke: 'currentColor',
|
|
480
|
+
strokeWidth: 1.5,
|
|
481
|
+
strokeLinecap: 'round',
|
|
482
|
+
strokeLinejoin: 'round',
|
|
483
|
+
}))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** `settings.action`: the rail toggle that sits left of the shipped buttons. */
|
|
487
|
+
function RailToggle(props) {
|
|
488
|
+
const t = translateOf(props)
|
|
489
|
+
const settings = useSettings()
|
|
490
|
+
const buttonRef = React.useRef(null)
|
|
491
|
+
const ownedNavId = React.useRef(false)
|
|
492
|
+
const [controls, setControls] = React.useState(NAV_ID)
|
|
493
|
+
const collapsed = settings.navCollapsed === true
|
|
494
|
+
|
|
495
|
+
React.useEffect(() => {
|
|
496
|
+
// Lend the host's <nav> an id so aria-controls has something to point at.
|
|
497
|
+
const button = buttonRef.current
|
|
498
|
+
const panel = typeof button?.closest === 'function' ? button.closest(PANEL) : null
|
|
499
|
+
const nav = panel === null ? null : panel.querySelector(':scope > nav')
|
|
500
|
+
if (nav === null) return undefined
|
|
501
|
+
if (nav.id === '') {
|
|
502
|
+
nav.id = NAV_ID
|
|
503
|
+
ownedNavId.current = true
|
|
504
|
+
setControls(NAV_ID)
|
|
505
|
+
} else {
|
|
506
|
+
setControls(nav.id)
|
|
507
|
+
}
|
|
508
|
+
return () => {
|
|
509
|
+
if (ownedNavId.current && nav.isConnected !== false) nav.removeAttribute('id')
|
|
510
|
+
}
|
|
511
|
+
}, [])
|
|
512
|
+
|
|
513
|
+
const label = collapsed ? t('rail.expand') : t('rail.collapse')
|
|
514
|
+
|
|
515
|
+
return h('button', {
|
|
516
|
+
ref: buttonRef,
|
|
517
|
+
type: 'button',
|
|
518
|
+
className: 'bsp-rail-toggle',
|
|
519
|
+
'aria-expanded': collapsed ? 'false' : 'true',
|
|
520
|
+
'aria-controls': controls,
|
|
521
|
+
'aria-label': label,
|
|
522
|
+
title: label,
|
|
523
|
+
onClick: () => {
|
|
524
|
+
writeSettings({ navCollapsed: !collapsed })
|
|
525
|
+
},
|
|
526
|
+
}, h(Chevron, { direction: collapsed ? 'right' : 'left' }))
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** `settings.section.icon`: two panes, one narrow — the thing this plugin does. */
|
|
530
|
+
function SectionIcon(props) {
|
|
531
|
+
const size = typeof props.size === 'number' ? props.size : 16
|
|
532
|
+
return h('svg', {
|
|
533
|
+
width: size,
|
|
534
|
+
height: size,
|
|
535
|
+
viewBox: '0 0 16 16',
|
|
536
|
+
fill: 'none',
|
|
537
|
+
'aria-hidden': 'true',
|
|
538
|
+
focusable: 'false',
|
|
539
|
+
}, [
|
|
540
|
+
h('rect', {
|
|
541
|
+
key: 'frame',
|
|
542
|
+
x: 2,
|
|
543
|
+
y: 3,
|
|
544
|
+
width: 12,
|
|
545
|
+
height: 10,
|
|
546
|
+
rx: 2,
|
|
547
|
+
stroke: 'currentColor',
|
|
548
|
+
strokeWidth: 1.3,
|
|
549
|
+
}),
|
|
550
|
+
h('path', {
|
|
551
|
+
key: 'rail',
|
|
552
|
+
d: 'M6.5 3v10',
|
|
553
|
+
stroke: 'currentColor',
|
|
554
|
+
strokeWidth: 1.3,
|
|
555
|
+
}),
|
|
556
|
+
])
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** `settings.section`: the two switches plus "restore defaults". */
|
|
560
|
+
function SettingsPage(props) {
|
|
561
|
+
const t = translateOf(props)
|
|
562
|
+
const settings = useSettings()
|
|
563
|
+
const [notice, setNotice] = React.useState('')
|
|
564
|
+
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
if (notice === '') return undefined
|
|
567
|
+
const id = setTimeout(() => setNotice(''), 4000)
|
|
568
|
+
return () => clearTimeout(id)
|
|
569
|
+
}, [notice])
|
|
570
|
+
|
|
571
|
+
/** Every write goes through here, so a failed write is never silent. */
|
|
572
|
+
const write = (patch) => {
|
|
573
|
+
writeSettings(patch)
|
|
574
|
+
if (lastError === 'write') setNotice(t('page.saveFailed'))
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const row = (titleKey, hintKey, checked, onToggle) => h('div', {
|
|
578
|
+
key: titleKey,
|
|
579
|
+
className: 'bsp-row',
|
|
580
|
+
}, [
|
|
581
|
+
h('div', { key: 'text', className: 'bsp-rowText' }, [
|
|
582
|
+
h('div', { key: 'label', className: 'bsp-rowLabel' }, t(titleKey)),
|
|
583
|
+
h('div', { key: 'hint', className: 'bsp-rowHint' }, t(hintKey)),
|
|
584
|
+
]),
|
|
585
|
+
h('input', {
|
|
586
|
+
key: 'check',
|
|
587
|
+
type: 'checkbox',
|
|
588
|
+
className: 'bsp-check',
|
|
589
|
+
checked,
|
|
590
|
+
'aria-label': t(titleKey),
|
|
591
|
+
onChange: (event) => onToggle(event.target.checked),
|
|
592
|
+
}),
|
|
593
|
+
])
|
|
594
|
+
|
|
595
|
+
return h('div', { className: 'bsp-page' }, [
|
|
596
|
+
h('div', { key: 'head', className: 'bsp-head' }, [
|
|
597
|
+
h('h2', { key: 'title', className: 'bsp-title' }, t('page.title')),
|
|
598
|
+
h('p', { key: 'intro', className: 'bsp-sub' }, t('page.intro')),
|
|
599
|
+
]),
|
|
600
|
+
row('page.fullscreen', 'page.fullscreenHint', settings.fullscreen === true,
|
|
601
|
+
(next) => write({ fullscreen: next })),
|
|
602
|
+
row('page.navCollapsed', 'page.navCollapsedHint', settings.navCollapsed === true,
|
|
603
|
+
(next) => write({ navCollapsed: next })),
|
|
604
|
+
h('div', { key: 'actions', className: 'bsp-actions' }, [
|
|
605
|
+
h('button', {
|
|
606
|
+
key: 'reset',
|
|
607
|
+
type: 'button',
|
|
608
|
+
className: 'bsp-button',
|
|
609
|
+
onClick: () => {
|
|
610
|
+
resetSettings()
|
|
611
|
+
setNotice(lastError === 'write' ? t('page.saveFailed') : t('page.resetDone'))
|
|
612
|
+
},
|
|
613
|
+
}, t('page.reset')),
|
|
614
|
+
h('span', { key: 'notice', className: 'bsp-notice', role: 'status' }, notice),
|
|
615
|
+
]),
|
|
616
|
+
h('p', { key: 'note', className: 'bsp-note' }, t('page.note')),
|
|
617
|
+
])
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
|
|
621
|
+
//#region entry
|
|
622
|
+
function apply(ctx) {
|
|
623
|
+
const t = ctx.locale.bind(NS)
|
|
624
|
+
cache = readSettings()
|
|
625
|
+
applyToDocument(cache)
|
|
626
|
+
|
|
627
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), `${NS}: dictionaries`)
|
|
628
|
+
|
|
629
|
+
ctx.effect(() => {
|
|
630
|
+
const style = document.createElement('style')
|
|
631
|
+
style.dataset.plugin = NS
|
|
632
|
+
style.textContent = CSS
|
|
633
|
+
document.head.appendChild(style)
|
|
634
|
+
return () => {
|
|
635
|
+
style.remove()
|
|
636
|
+
clearDocument()
|
|
637
|
+
}
|
|
638
|
+
}, `${NS}: styles`)
|
|
639
|
+
|
|
640
|
+
ctx.effect(() => subscribeSettings(applyToDocument), `${NS}: attributes`)
|
|
641
|
+
ctx.effect(() => watchStorage(), `${NS}: storage`)
|
|
642
|
+
|
|
643
|
+
ctx.slots.inject('settings.action', () => ctx.slots.register({
|
|
644
|
+
name: 'settings.action',
|
|
645
|
+
id: ACTION_ID,
|
|
646
|
+
order: 0,
|
|
647
|
+
locale: NS,
|
|
648
|
+
}, (props) => h(RailToggle, { t })))
|
|
649
|
+
|
|
650
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
651
|
+
name: 'settings.section',
|
|
652
|
+
id: SECTION_ID,
|
|
653
|
+
order: SECTION_ORDER,
|
|
654
|
+
label: () => t('section.nav'),
|
|
655
|
+
locale: NS,
|
|
656
|
+
}, (props) => h(SettingsPage, { close: props.close, t })))
|
|
657
|
+
|
|
658
|
+
ctx.slots.inject('settings.section.icon', () => ctx.slots.register({
|
|
659
|
+
name: 'settings.section.icon',
|
|
660
|
+
id: SECTION_ID,
|
|
661
|
+
order: SECTION_ORDER,
|
|
662
|
+
}, SectionIcon))
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const module = {
|
|
666
|
+
inject: ['slots', 'locale'],
|
|
667
|
+
apply,
|
|
668
|
+
// Test surface. The loader only reads `apply` / `inject`; the rest exist so
|
|
669
|
+
// `test/settings-page.test.mjs` can assert on the real strings and store.
|
|
670
|
+
VERSION,
|
|
671
|
+
NS,
|
|
672
|
+
CSS,
|
|
673
|
+
STORE_KEY,
|
|
674
|
+
SETTINGS_VERSION,
|
|
675
|
+
DEFAULTS,
|
|
676
|
+
FULLSCREEN_ATTR,
|
|
677
|
+
NAV_ATTR,
|
|
678
|
+
NAV_ID,
|
|
679
|
+
PANEL,
|
|
680
|
+
SECTION_ID,
|
|
681
|
+
SECTION_ORDER,
|
|
682
|
+
ACTION_ID,
|
|
683
|
+
ZH,
|
|
684
|
+
EN,
|
|
685
|
+
sanitizeSettings,
|
|
686
|
+
readSettings,
|
|
687
|
+
writeSettings,
|
|
688
|
+
resetSettings,
|
|
689
|
+
subscribeSettings,
|
|
690
|
+
applyToDocument,
|
|
691
|
+
clearDocument,
|
|
692
|
+
currentSettings,
|
|
693
|
+
lastErrorOf: () => lastError,
|
|
694
|
+
RailToggle,
|
|
695
|
+
SettingsPage,
|
|
696
|
+
SectionIcon,
|
|
697
|
+
Chevron,
|
|
15
698
|
}
|
|
699
|
+
return module
|
|
16
700
|
},
|
|
17
|
-
})
|
|
701
|
+
})
|
package/cordis.patch.yml
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Bundle patch: one row for dsh-better-setting-page.
|
|
2
|
+
# The client half (dsh.client) owns the whole feature: one stylesheet that turns
|
|
3
|
+
# the settings dialog into a full-window page, an action button that collapses
|
|
4
|
+
# the navigation rail, and a small settings section holding the two switches.
|
|
5
|
+
# The host half injects nothing; it exists so this patch resolves.
|
|
2
6
|
- insert:
|
|
3
7
|
- id: better-setting-page
|
|
4
|
-
name: 'dsh-better-setting-page'
|
|
8
|
+
name: 'dsh-better-setting-page'
|
package/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half of `dsh-better-setting-page
|
|
2
|
+
* Host half of `dsh-better-setting-page`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The whole feature is a browser-side stylesheet plus two slot entries, so this
|
|
5
|
+
* half only has to exist for the bundle to resolve and for the Cordis patch to
|
|
6
|
+
* have something to mount. It injects no service and serves no route.
|
|
7
7
|
*/
|
|
8
8
|
export const name = 'dsh-better-setting-page'
|
|
9
9
|
export const inject = []
|
|
10
10
|
export function apply(ctx) {
|
|
11
11
|
void ctx
|
|
12
|
-
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-better-setting-page",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Full-screen settings page with a collapsible navigation rail for DeepSeek Harness, plus a small page holding the two switches.",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"author": { "name": "MeruSu" },
|
|
7
8
|
"repository": { "type": "git", "url": "git+https://github.com/MeruSu/dsh-better-setting-page.git" },
|
|
8
|
-
"keywords": ["dsh", "deepseek-harness", "
|
|
9
|
+
"keywords": ["dsh", "dsh-plugin", "deepseek-harness", "web-ui", "settings"],
|
|
9
10
|
"type": "module",
|
|
11
|
+
"main": "index.js",
|
|
10
12
|
"exports": {
|
|
11
13
|
".": "./index.js",
|
|
12
14
|
"./client": "./client.js",
|
|
13
15
|
"./package.json": "./package.json"
|
|
14
16
|
},
|
|
15
|
-
"files": ["index.js", "client.js", "cordis.patch.yml", "README.md", "LICENSE"],
|
|
17
|
+
"files": ["index.js", "client.js", "cordis.patch.yml", "README.md", "README.zh.md", "LICENSE"],
|
|
16
18
|
"dsh": {
|
|
17
19
|
"bundle": { "patch": "./cordis.patch.yml" },
|
|
18
|
-
"client": {
|
|
20
|
+
"client": {
|
|
21
|
+
"platform": "web",
|
|
22
|
+
"immediately": true
|
|
23
|
+
}
|
|
19
24
|
}
|
|
20
|
-
}
|
|
25
|
+
}
|