dsh-ctrl-wheel-zoom 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 <copyright holder>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,147 @@
1
- # Temporary Holding Version
1
+ # Ctrl + Wheel Zoom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hold **Ctrl** (or **⌘** on macOS) and roll the mouse wheel to zoom the whole Harness Web UI.
4
+ A page-zoom plugin for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web GUI, distributed as an ordinary
5
+ DSH **profile bundle**: no build step, no runtime dependency, plain ESM.
6
+
7
+ ## What it does
8
+
9
+ | Gesture | Result |
10
+ |---|---|
11
+ | `Ctrl` / `⌘` + wheel | Zooms the page around the pointer, 50%–200%, in 10% steps |
12
+ | `Ctrl` / `⌘` + `+` / `-` | Same from the keyboard |
13
+ | `Ctrl` / `⌘` + `0` | Back to 100% |
14
+ | Zoom chip (bottom-right) | Appears whenever the level is not 100%: click the percentage to reset, click **Fit to width** to fit the window |
15
+ | Settings → General | A **Page zoom** row with the host's stepper pattern and a reset button |
16
+
17
+ The chosen level is remembered in the browser (`localStorage`), and every listener, stylesheet and
18
+ the applied `zoom` property are removed when the plugin is disabled or unloaded.
19
+
20
+ A gesture that another component already handled (`preventDefault()`, e.g. an embedded code editor
21
+ or map with its own `Ctrl`+wheel zoom) is never stolen.
22
+
23
+ ## Install
24
+
25
+ Ask the Harness agent to install it, or call the plugin manager yourself with the package located
26
+ by absolute path, a tarball, or a published name:
27
+
28
+ ```
29
+ plugin_manager action: install_bundle
30
+ target: <absolute path to this package / a .tgz / a published name>
31
+ ```
32
+
33
+ `install_bundle` links the package into the active profile (`$DSH_PROFILE_DIR`), adds it to the
34
+ profile's bundle list, and the Harness applies it live. A page refresh picks up the browser half in
35
+ a page that was already open.
36
+
37
+ To install it by hand instead, add the package to the profile's dependencies and to
38
+ `dsh.profile.bundles` in `$DSH_PROFILE_DIR/package.json`, then restart the Harness.
39
+
40
+ ## Uninstall
41
+
42
+ ```
43
+ plugin_manager action: remove_bundle
44
+ target: dsh-ctrl-wheel-zoom
45
+ ```
46
+
47
+ ## Requirements
48
+
49
+ - A Harness Web GUI (the client half declares `platform: "web"`; it is inert without a browser).
50
+ - Nothing else: the package has no dependencies, no build output, and no install scripts. Built-in
51
+ client modules (`react`, `@deepseek-ai/dsh-client-store`) come from the shell's own module table.
52
+
53
+ ## Sharing and publishing
54
+
55
+ The package is self-contained, so there are three ways to hand it to someone else.
56
+
57
+ ### 1. A tarball
58
+
59
+ ```bash
60
+ pnpm pack # writes dsh-ctrl-wheel-zoom-1.0.0.tgz
61
+ ```
62
+
63
+ Send that `.tgz`; the receiver installs it by path:
64
+
65
+ ```
66
+ plugin_manager action: install_bundle
67
+ target: <path to dsh-ctrl-wheel-zoom-1.0.0.tgz>
68
+ ```
69
+
70
+ (`pnpm pack` writes the tarball beside `package.json`. DSH's own runtime ships a pnpm, so nothing
71
+ has to be installed first.)
72
+
73
+ ### 2. The directory (zip / repository)
74
+
75
+ Zip this folder (or push it to a Git repository) and have the receiver install the **unpacked
76
+ directory** by path — `install_bundle` accepts a directory, and a folder is enough because there is
77
+ no build step.
78
+
79
+ ### 3. npm (install by name for everyone)
80
+
81
+ This manifest is already publishable: `name` is the unscoped `dsh-ctrl-wheel-zoom`, the row in
82
+ `cordis.patch.yml` names the same specifier, and there is no `"private": true`.
83
+
84
+ npm no longer issues long-lived "classic" tokens — they were permanently revoked on 2025-12-09 —
85
+ and publishing requires two-factor authentication. Pick one of the two ways through:
86
+
87
+ ```bash
88
+ # A. A granular access token created with "Bypass two-factor authentication" enabled,
89
+ # stored once as a credential:
90
+ # //registry.npmjs.org/:_authToken=npm_... in ~/.npmrc
91
+ pnpm publish --no-git-checks
92
+
93
+ # B. Any write token, with a one-time password supplied per publish:
94
+ pnpm publish --no-git-checks --otp=<6-digit code>
95
+ ```
96
+
97
+ A token without bypass-2FA is refused with `403 ... Two-factor authentication or granular access
98
+ token with bypass 2fa enabled is required to publish packages`. Verify what a token can actually do
99
+ before blaming the command:
100
+
101
+ ```bash
102
+ curl -H "Authorization: Bearer $TOKEN" https://registry.npmjs.org/-/npm/v1/tokens
103
+ # "bypass_2fa": false → use --otp, or recreate the token with the switch on
104
+ # "permissions": [{"name":"package","action":"write"}] → publishing is permitted at all
105
+ ```
106
+
107
+ Note npm's stated direction: bypass-2FA tokens are on a path to removal (targeted for January 2027)
108
+ in favour of staged publishing or trusted publishing, so a long-lived automated release will need
109
+ one of those later.
110
+
111
+ Keep `name` in `package.json` and the `name:` line of `cordis.patch.yml` identical in every
112
+ release — the loader resolves the row through that specifier. Bump `version` for each release.
113
+
114
+ Afterwards anyone installs it by name:
115
+
116
+ ```
117
+ plugin_manager action: install_bundle
118
+ target: dsh-ctrl-wheel-zoom
119
+ ```
120
+
121
+ ## Files
122
+
123
+ | File | Role |
124
+ |---|---|
125
+ | `index.js` | Host half: an empty `apply()`; the row exists so the bundle can be installed and toggled |
126
+ | `client.js` | Browser half: wheel/keyboard gestures, zoom state, chip and settings-row registrations, stylesheets |
127
+ | `cordis.patch.yml` | The loader patch that inserts the `ctrl-wheel-zoom` row |
128
+ | `locale/{zh,en}.json` | Plugin-card title/description text; the row's own copy lives in `client.js` |
129
+ | `icon.svg` | Plugin-manager card artwork |
130
+
131
+ ## How the zoom works
132
+
133
+ Zoom is CSS `zoom` on the document element. It scales layout, text and fixed overlay layers
134
+ together exactly like the browser's own page zoom, while the app's own scroll containers keep
135
+ working — a `transform: scale()` wrapper cannot scroll its oversized content, and a font-size
136
+ change would only touch text.
137
+
138
+ The wheel listener is registered in the capture phase on `window`, so it sees the gesture before
139
+ the app's scroll containers and blocks them from scrolling while `Ctrl` is held. When the viewport
140
+ itself scrolls, the plugin re-anchors it so the content under the pointer stays under the pointer.
141
+
142
+ Both UI surfaces read one store instance shared with the gesture writer, so the chip, the settings
143
+ stepper and the wheel listener can never disagree about the current level.
144
+
145
+ ## License
146
+
147
+ MIT — see [LICENSE](LICENSE).
package/client.js ADDED
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Client half of the Ctrl + Wheel Zoom bundle.
3
+ *
4
+ * CTRL (or ⌘ on macOS) + mouse wheel zooms the whole Web UI around the pointer;
5
+ * CTRL + `+` / `-` / `0` do the same from the keyboard, and a chip pinned at the
6
+ * frame's bottom-right corner reports the level whenever it is not 100% and
7
+ * returns to 100% in one click.
8
+ *
9
+ * Why CSS `zoom` on the document element: it scales layout, text and fixed
10
+ * overlay layers together, exactly like the browser's own page zoom, while the
11
+ * app's own scroll containers keep working. A `transform: scale()` wrapper
12
+ * cannot scroll its oversized content and a font-size change would only touch
13
+ * text, so both were rejected.
14
+ *
15
+ * The wheel listener is registered in the capture phase on `window`: it sees the
16
+ * gesture before the app's scroll containers, and while CTRL is held it always
17
+ * blocks those containers from scrolling. A handler that already called
18
+ * `preventDefault()` (an embedded editor or map with its own CTRL+wheel zoom)
19
+ * wins, because we never steal an already-handled gesture.
20
+ *
21
+ * All zoom state lives in one store instance that both registered surfaces and
22
+ * the apply-world writer share, so the chip, the settings stepper and the wheel
23
+ * listener can never disagree. Everything the plugin registers lives inside
24
+ * `ctx.effect`, so unloading it removes both slot entries, both listeners, the
25
+ * stylesheets and the zoom value it applied.
26
+ */
27
+ window.__ModuleLoader__.load({
28
+ id: '@local/dsh-ctrl-wheel-zoom',
29
+ factory(require) {
30
+ const React = require('react');
31
+ const { defineStore } = require('@deepseek-ai/dsh-client-store');
32
+ const h = React.createElement;
33
+
34
+ /** This package's id: the module-loader id, also the stylesheet tag owner. */
35
+ const PLUGIN_ID = '@local/dsh-ctrl-wheel-zoom';
36
+
37
+ /** Locale namespace and dictionaries (the zh key set is the source of truth). */
38
+ const NS = 'ctrl-wheel-zoom';
39
+ const ZH = {
40
+ 'chip.fit': '适合宽度',
41
+ 'chip.label': '页面缩放',
42
+ 'chip.percent': '{percent}%',
43
+ 'chip.reset': '恢复 100%',
44
+ 'chip.title': '页面缩放 {percent}%',
45
+ 'fit.title': '缩放以适合宽度',
46
+ 'reset.title': '恢复 100%',
47
+ 'row.decrease': '缩小页面',
48
+ 'row.description': '按住 Ctrl 并滚动鼠标滚轮即可缩放整个页面',
49
+ 'row.fit': '适合宽度',
50
+ 'row.increase': '放大页面',
51
+ 'row.reset': '恢复 100%',
52
+ 'row.title': '页面缩放',
53
+ 'row.unit': '%',
54
+ };
55
+ const EN = {
56
+ 'chip.fit': 'Fit to width',
57
+ 'chip.label': 'Page zoom',
58
+ 'chip.percent': '{percent}%',
59
+ 'chip.reset': 'Reset to 100%',
60
+ 'chip.title': 'Page zoom {percent}%',
61
+ 'fit.title': 'Zoom to fit width',
62
+ 'reset.title': 'Reset to 100%',
63
+ 'row.decrease': 'Zoom out',
64
+ 'row.description': 'Hold Ctrl and roll the mouse wheel to zoom the whole page',
65
+ 'row.fit': 'Fit to width',
66
+ 'row.increase': 'Zoom in',
67
+ 'row.reset': 'Reset to 100%',
68
+ 'row.title': 'Page zoom',
69
+ 'row.unit': '%',
70
+ };
71
+
72
+ /** Zoom bounds and step, in percent. */
73
+ const MIN = 50;
74
+ const MAX = 200;
75
+ const DEFAULT = 100;
76
+ const STEP = 10;
77
+ /** Wheel deltas below this accumulate instead of stepping, so trackpads feel right. */
78
+ const WHEEL_THRESHOLD = 45;
79
+ /** Browser-local zoom memory (display state, not durable profile settings). */
80
+ const STORAGE_KEY = 'dsh.ctrl-wheel-zoom.value';
81
+
82
+ /** The document element, or undefined outside a browser. */
83
+ function root() {
84
+ return typeof document === 'undefined' ? undefined : document.documentElement;
85
+ }
86
+
87
+ /** Clamp into the zoom range; anything unusable resolves to 100%. */
88
+ function clamp(value) {
89
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT;
90
+ return Math.min(MAX, Math.max(MIN, Math.round(value)));
91
+ }
92
+
93
+ /**
94
+ * Read the remembered zoom. An unreadable or out-of-range entry resolves to
95
+ * 100%, so corrupted storage can never trap the UI at an unreadable scale.
96
+ */
97
+ function loadValue() {
98
+ try {
99
+ const raw = window.localStorage.getItem(STORAGE_KEY);
100
+ if (raw === null) return DEFAULT;
101
+ const value = Number(raw);
102
+ return Number.isFinite(value) ? clamp(value) : DEFAULT;
103
+ } catch (error) {
104
+ return DEFAULT;
105
+ }
106
+ }
107
+
108
+ /** Persist the zoom; storage failures (quota, private mode) only lose memory. */
109
+ function saveValue(value) {
110
+ try {
111
+ window.localStorage.setItem(STORAGE_KEY, String(value));
112
+ } catch (error) {
113
+ /* best effort: the zoom still applies for this page */
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Apply a level to the document. 100% removes the property entirely, which
119
+ * restores the untouched layout instead of pinning `zoom: 1`.
120
+ */
121
+ function applyZoom(value) {
122
+ const element = root();
123
+ if (element === undefined) return;
124
+ if (value === DEFAULT) element.style.removeProperty('zoom');
125
+ else element.style.setProperty('zoom', String(value / 100));
126
+ }
127
+
128
+ /**
129
+ * Re-anchor the viewport so the content the pointer rested on stays under the
130
+ * pointer. Only an already-scrolling viewport is corrected: inside an app
131
+ * whose columns scroll themselves this is a no-op, so zooming never moves
132
+ * content the user did not point at.
133
+ */
134
+ function keepAnchor(element, clientX, clientY, beforeZoom, afterZoom) {
135
+ const top = element.scrollTop;
136
+ const left = element.scrollLeft;
137
+ if (top === 0 && left === 0) return;
138
+ const ratio = afterZoom / beforeZoom;
139
+ element.scrollTop = (top + clientY) * ratio - clientY;
140
+ element.scrollLeft = (left + clientX) * ratio - clientX;
141
+ }
142
+
143
+ /**
144
+ * Chip styles: one floating pill anchored to the frame's bottom-right corner
145
+ * through a fixed wrapper, which stays correct whatever containing block the
146
+ * overlay layer establishes. Host tokens only.
147
+ */
148
+ const CHIP_CSS = `
149
+ .dshZoomChip_anchor{position:fixed;right:16px;bottom:16px;z-index:4}
150
+ .dshZoomChip_root{box-sizing:border-box;display:inline-flex;align-items:center;gap:4px;padding:4px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
151
+ .dshZoomChip_button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:44px;padding:0 8px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;color:inherit;font:inherit;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;cursor:pointer}
152
+ .dshZoomChip_button:hover{background:var(--dsw-alias-interactive-bg-hover)}
153
+ .dshZoomChip_button:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
154
+ .dshZoomChip_value{font-weight:500}
155
+ .dshZoomChip_divider{width:.5px;height:16px;background:var(--dsw-alias-border-l3)}
156
+ `;
157
+
158
+ /** General-row styles: the Appearance section's row and stepper geometry. */
159
+ const ROW_CSS = `
160
+ .dshZoomRow_row{box-sizing:border-box;display:flex;align-items:center;gap:8px;padding:16px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
161
+ .dshZoomRow_rowText{display:flex;flex-direction:column;flex:1;gap:4px;min-width:0;padding-right:48px}
162
+ .dshZoomRow_title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:400;line-height:22px}
163
+ .dshZoomRow_desc{color:var(--dsw-alias-label-tertiary);font-size:12px;font-weight:400;line-height:18px}
164
+ .dshZoomRow_control{display:inline-flex;align-items:center;gap:8px}
165
+ .dshZoomRow_stepper{box-sizing:border-box;display:inline-flex;position:relative;align-items:center;justify-content:center;min-width:72px;height:36px;border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-module-platform)}
166
+ .dshZoomRow_value{min-width:18px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-variant-numeric:tabular-nums;text-align:center}
167
+ .dshZoomRow_unit{color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
168
+ .dshZoomRow_arrows{display:flex;position:absolute;right:8px;flex-direction:column;gap:2px;opacity:0}
169
+ .dshZoomRow_stepper:hover .dshZoomRow_arrows,.dshZoomRow_stepper:focus-within .dshZoomRow_arrows{opacity:1}
170
+ .dshZoomRow_arrow{display:inline-flex;align-items:center;justify-content:center;width:17px;height:12px;padding:0;border:none;border-radius:var(--dsw-radius-xs);background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 75%, transparent);color:var(--dsw-alias-label-primary);cursor:pointer}
171
+ .dshZoomRow_arrow:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
172
+ .dshZoomRow_arrow:disabled{color:var(--dsw-alias-label-caption);cursor:default}
173
+ .dshZoomRow_arrow:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
174
+ .dshZoomRow_fit{height:36px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer}
175
+ .dshZoomRow_fit:hover{background:var(--dsw-alias-interactive-bg-hover)}
176
+ .dshZoomRow_fit:focus-visible{outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-width:var(--dsw-focus-ring-width)}
177
+ `;
178
+
179
+ /**
180
+ * The zoom chip (`shell.overlay`). It renders only while the level is not
181
+ * 100%, which keeps the frame untouched in the default state; clicking the
182
+ * percentage returns to 100%.
183
+ */
184
+ function ZoomChip({ actions, t, useStore }) {
185
+ const zoom = useStore((state) => state.zoom);
186
+ if (zoom === DEFAULT) return null;
187
+ return h(
188
+ 'div',
189
+ { className: 'dshZoomChip_anchor' },
190
+ h(
191
+ 'div',
192
+ { className: 'dshZoomChip_root', role: 'group', 'aria-label': t('chip.label') },
193
+ h(
194
+ 'button',
195
+ {
196
+ type: 'button',
197
+ className: 'dshZoomChip_button',
198
+ title: t('chip.title', { percent: zoom }),
199
+ onClick: actions.reset,
200
+ },
201
+ h('span', { className: 'dshZoomChip_value' }, t('chip.percent', { percent: zoom })),
202
+ ),
203
+ h('span', { className: 'dshZoomChip_divider', 'aria-hidden': true }),
204
+ h(
205
+ 'button',
206
+ {
207
+ type: 'button',
208
+ className: 'dshZoomChip_button',
209
+ title: t('fit.title'),
210
+ onClick: actions.fit,
211
+ },
212
+ t('chip.fit'),
213
+ ),
214
+ ),
215
+ );
216
+ }
217
+
218
+ /**
219
+ * Stepper glyphs. The host's chevron geometry, drawn locally: a plugin must
220
+ * not load a Client package such as ui-primitives as a module.
221
+ */
222
+ function Chevron({ direction }) {
223
+ const up = direction === 'up';
224
+ return h(
225
+ 'svg',
226
+ {
227
+ width: 9,
228
+ height: 9,
229
+ viewBox: '0 0 16 16',
230
+ fill: 'none',
231
+ stroke: 'currentColor',
232
+ strokeWidth: 1.6,
233
+ strokeLinecap: 'round',
234
+ strokeLinejoin: 'round',
235
+ 'aria-hidden': true,
236
+ },
237
+ h('path', { d: up ? 'M4 10L8 6L12 10' : 'M4 6L8 10L12 6' }),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * The General settings row: the Appearance section's stepper pattern, for
243
+ * users who never discover the wheel gesture.
244
+ */
245
+ function ZoomRow({ t, setZoom, useStore }) {
246
+ const zoom = useStore((state) => state.zoom);
247
+ return h(
248
+ 'div',
249
+ { className: 'dshZoomRow_row' },
250
+ h(
251
+ 'div',
252
+ { className: 'dshZoomRow_rowText' },
253
+ h('div', { className: 'dshZoomRow_title' }, t('row.title')),
254
+ h('div', { className: 'dshZoomRow_desc' }, t('row.description')),
255
+ ),
256
+ h(
257
+ 'div',
258
+ { className: 'dshZoomRow_control' },
259
+ h(
260
+ 'div',
261
+ { className: 'dshZoomRow_stepper' },
262
+ h('span', { className: 'dshZoomRow_value' }, zoom),
263
+ h(
264
+ 'span',
265
+ { className: 'dshZoomRow_arrows' },
266
+ h(
267
+ 'button',
268
+ {
269
+ type: 'button',
270
+ className: 'dshZoomRow_arrow',
271
+ 'aria-label': t('row.increase'),
272
+ disabled: zoom >= MAX,
273
+ onClick: () => {
274
+ setZoom(zoom + STEP);
275
+ },
276
+ },
277
+ h(Chevron, { direction: 'up' }),
278
+ ),
279
+ h(
280
+ 'button',
281
+ {
282
+ type: 'button',
283
+ className: 'dshZoomRow_arrow',
284
+ 'aria-label': t('row.decrease'),
285
+ disabled: zoom <= MIN,
286
+ onClick: () => {
287
+ setZoom(zoom - STEP);
288
+ },
289
+ },
290
+ h(Chevron, { direction: 'down' }),
291
+ ),
292
+ ),
293
+ ),
294
+ h('span', { className: 'dshZoomRow_unit' }, t('row.unit')),
295
+ h(
296
+ 'button',
297
+ {
298
+ type: 'button',
299
+ className: 'dshZoomRow_fit',
300
+ title: t('fit.title'),
301
+ onClick: () => {
302
+ setZoom(DEFAULT);
303
+ },
304
+ },
305
+ t('row.reset'),
306
+ ),
307
+ ),
308
+ );
309
+ }
310
+
311
+ return {
312
+ inject: ['slots', 'locale'],
313
+ apply(ctx) {
314
+ const element = root();
315
+ if (element === undefined) return;
316
+
317
+ ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'ctrl-wheel-zoom: locale dictionaries');
318
+
319
+ /**
320
+ * Mount one plugin-owned global stylesheet for exactly the owning plugin
321
+ * lifetime. An installed client bundle has NO `styles` builtin — that symbol
322
+ * belongs to the dynamic package runner — so the tag is created here and
323
+ * removed by the same effect's cleanup, the pattern ui-theme uses for its own
324
+ * sheets. Getting this wrong throws inside `apply`, which the Web boot
325
+ * reports as a failed entry and refuses to mount the application.
326
+ *
327
+ * @param name - stylesheet name, used for the tag's `data-plugin-css`
328
+ * @param css - raw CSS text
329
+ */
330
+ function installStyle(name, css) {
331
+ return ctx.effect(() => {
332
+ const tag = document.createElement('style');
333
+ tag.dataset.plugin = PLUGIN_ID;
334
+ tag.dataset.pluginCss = `${PLUGIN_ID}/${name}`;
335
+ tag.textContent = css;
336
+ document.head.appendChild(tag);
337
+ return () => {
338
+ tag.remove();
339
+ };
340
+ }, `ctrl-wheel-zoom: ${name} stylesheet`);
341
+ }
342
+
343
+ installStyle('chip.css', CHIP_CSS);
344
+ installStyle('row.css', ROW_CSS);
345
+
346
+ /**
347
+ * The store handle plus the one root instance every surface reads: the
348
+ * renderer keys resolved instances per handle and per registration, so the
349
+ * first registration publishes the instance this writer also mutates and
350
+ * both entries thereafter share it. Zoom can therefore never be reported
351
+ * twice with two different values.
352
+ */
353
+ const store = defineStore({
354
+ init: () => ({ zoom: loadValue() }),
355
+ actions: {
356
+ set: (draft, value) => {
357
+ draft.zoom = clamp(value);
358
+ },
359
+ },
360
+ });
361
+ const instance = store.create();
362
+
363
+ const current = () => instance.getSnapshot().zoom;
364
+
365
+ /* Restore the remembered level before any surface mounts. */
366
+ applyZoom(current());
367
+
368
+ let pendingWheel = 0;
369
+ let lastWheelAt = 0;
370
+ let selfScrollAt = 0;
371
+
372
+ /**
373
+ * The single write path: re-anchor for a pointer gesture, remember the
374
+ * level, publish it, then apply it to the document.
375
+ *
376
+ * @param value - requested level in percent
377
+ * @param anchor - pointer position for a wheel gesture; omit for keyboard
378
+ * @returns the effective level
379
+ */
380
+ function setZoom(value, anchor) {
381
+ const before = current();
382
+ const next = clamp(value);
383
+ if (next !== before) {
384
+ if (anchor !== undefined) keepAnchor(element, anchor.x, anchor.y, before, next);
385
+ saveValue(next);
386
+ instance.actions.set(next);
387
+ }
388
+ applyZoom(next);
389
+ return next;
390
+ }
391
+
392
+ /** The level at which the layout exactly fills the window width. */
393
+ function fitValue() {
394
+ const width = element.clientWidth;
395
+ const scrollWidth = element.scrollWidth;
396
+ if (!width || !scrollWidth) return DEFAULT;
397
+ return clamp((width / scrollWidth) * 100);
398
+ }
399
+
400
+ /** One wheel notch, in the gesture's direction, anchored at the pointer. */
401
+ function onWheel(event) {
402
+ if (!event.ctrlKey && !event.metaKey) return;
403
+ if (event.defaultPrevented) return;
404
+ if (event.timeStamp - selfScrollAt < 50) return;
405
+ event.preventDefault();
406
+ if (event.timeStamp - lastWheelAt < 20) return;
407
+ lastWheelAt = event.timeStamp;
408
+ pendingWheel += event.deltaY;
409
+ if (Math.abs(pendingWheel) < WHEEL_THRESHOLD) return;
410
+ const direction = pendingWheel < 0 ? 1 : -1;
411
+ pendingWheel = 0;
412
+ setZoom(current() + direction * STEP, { x: event.clientX, y: event.clientY });
413
+ }
414
+
415
+ /** CTRL/⌘ + `+`, `-`, `0`: keyboard parity with the wheel gesture. */
416
+ function onKeydown(event) {
417
+ if (event.defaultPrevented || event.isComposing) return;
418
+ if (!event.ctrlKey && !event.metaKey) return;
419
+ if (event.altKey) return;
420
+ const key = event.key;
421
+ let wanted;
422
+ if (key === '0' || key === ')') wanted = DEFAULT;
423
+ else if (key === '=' || key === '+') wanted = current() + STEP;
424
+ else if (key === '-' || key === '_') wanted = current() - STEP;
425
+ else return;
426
+ /* Swallow the chord even at a bound, so the page's own zoom never drifts from ours. */
427
+ event.preventDefault();
428
+ if (clamp(wanted) !== current()) setZoom(wanted);
429
+ pendingWheel = 0;
430
+ }
431
+
432
+ /** A scroll right after a zoom is our own correction, never a user gesture. */
433
+ function onScroll() {
434
+ selfScrollAt = performance.now();
435
+ }
436
+
437
+ ctx.effect(() => {
438
+ window.addEventListener('wheel', onWheel, { capture: true, passive: false });
439
+ window.addEventListener('keydown', onKeydown, true);
440
+ element.addEventListener('scroll', onScroll, { passive: true });
441
+ applyZoom(current());
442
+ return () => {
443
+ window.removeEventListener('wheel', onWheel, { capture: true });
444
+ window.removeEventListener('keydown', onKeydown, true);
445
+ element.removeEventListener('scroll', onScroll);
446
+ element.style.removeProperty('zoom');
447
+ };
448
+ }, 'ctrl-wheel-zoom: gestures');
449
+
450
+ /* Service registration goes through an effect: nothing in `apply` itself may throw. */
451
+ ctx.effect(() => ctx.provide('ctrlWheelZoom', {
452
+ getZoom: current,
453
+ setZoom: (value) => setZoom(value),
454
+ stepZoom: (direction) => setZoom(current() + direction * STEP),
455
+ resetZoom: () => setZoom(DEFAULT),
456
+ fitToWidth: () => setZoom(fitValue()),
457
+ store,
458
+ }), 'ctrl-wheel-zoom: service');
459
+
460
+ ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
461
+ name: 'shell.overlay',
462
+ id: 'ctrl-wheel-zoom-chip',
463
+ order: 60,
464
+ store: instance,
465
+ locale: NS,
466
+ inject: () => ({
467
+ actions: {
468
+ reset: () => setZoom(DEFAULT),
469
+ fit: () => setZoom(fitValue()),
470
+ },
471
+ }),
472
+ }, ZoomChip)), 'ctrl-wheel-zoom: zoom chip');
473
+
474
+ ctx.effect(() => ctx.slots.inject('settings.general.item', () => ctx.slots.register({
475
+ name: 'settings.general.item',
476
+ id: 'ctrl-wheel-zoom',
477
+ order: 12,
478
+ store: instance,
479
+ locale: NS,
480
+ inject: () => ({ setZoom: (value) => setZoom(value) }),
481
+ }, ZoomRow)), 'ctrl-wheel-zoom: settings row');
482
+ },
483
+ };
484
+ },
485
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: ctrl-wheel-zoom
3
+ name: 'dsh-ctrl-wheel-zoom'
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="36" height="36" fill="none" role="img" aria-label="Ctrl + wheel zoom">
2
+ <circle cx="16" cy="16" r="9.25" stroke="#4176E6" stroke-width="2.5"/>
3
+ <path d="M22.9 22.9L29 29" stroke="#4176E6" stroke-width="2.5" stroke-linecap="round"/>
4
+ <path d="M12.5 16H19.5" stroke="#4176E6" stroke-width="2" stroke-linecap="round"/>
5
+ <path d="M16 12.5V19.5" stroke="#4176E6" stroke-width="2" stroke-linecap="round"/>
6
+ </svg>
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Host half of the Ctrl + Wheel Zoom bundle.
3
+ *
4
+ * Page zoom is a browser-side concern, so the Host contributes no service and
5
+ * no tool: this row exists so the plugin manager can install, list, enable and
6
+ * disable the bundle. The Client half ({@link ./client.js}) owns the wheel
7
+ * listener, the zoom state and the two UI surfaces.
8
+ */
9
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "chip.label": "Page zoom",
3
+ "chip.fit": "Fit to width",
4
+ "chip.percent": "{percent}%",
5
+ "chip.reset": "Reset to 100%",
6
+ "chip.title": "Page zoom {percent}%",
7
+ "fit.title": "Zoom to fit width",
8
+ "reset.title": "Reset to 100%",
9
+ "row.title": "Page zoom",
10
+ "row.description": "Hold Ctrl and roll the mouse wheel to zoom the whole page",
11
+ "row.unit": "%",
12
+ "row.decrease": "Zoom out",
13
+ "row.increase": "Zoom in",
14
+ "row.reset": "Reset to 100%",
15
+ "row.fit": "Fit to width"
16
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "chip.label": "页面缩放",
3
+ "chip.fit": "适合宽度",
4
+ "chip.percent": "{percent}%",
5
+ "chip.reset": "恢复 100%",
6
+ "chip.title": "页面缩放 {percent}%",
7
+ "fit.title": "缩放以适合宽度",
8
+ "reset.title": "恢复 100%",
9
+ "row.title": "页面缩放",
10
+ "row.description": "按住 Ctrl 并滚动鼠标滚轮即可缩放整个页面",
11
+ "row.unit": "%",
12
+ "row.decrease": "缩小页面",
13
+ "row.increase": "放大页面",
14
+ "row.reset": "恢复 100%",
15
+ "row.fit": "适合宽度"
16
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "dsh-ctrl-wheel-zoom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Ctrl+wheel page zoom for the DeepSeek Harness Web UI, with an on-screen zoom chip and a General settings row",
5
+ "type": "module",
6
+ "icon": "./icon.svg",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./package.json": "./package.json",
11
+ "./locale/*.json": "./locale/*.json"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "cordis.patch.yml",
17
+ "icon.svg",
18
+ "README.md",
19
+ "LICENSE",
20
+ "locale/*.json"
21
+ ],
22
+ "keywords": [
23
+ "dsh",
24
+ "deepseek-harness",
25
+ "dsh-plugin",
26
+ "dsh-bundle",
27
+ "zoom",
28
+ "page-zoom",
29
+ "accessibility"
30
+ ],
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "meta": {
36
+ "title": "Ctrl + Wheel Zoom",
37
+ "description": "Hold Ctrl and roll the mouse wheel to zoom the whole page, with an on-screen zoom chip and General settings stepper."
38
+ },
39
+ "dsh": {
40
+ "bundle": {
41
+ "patch": "./cordis.patch.yml"
42
+ },
43
+ "client": {
44
+ "platform": "web",
45
+ "immediately": true,
46
+ "inject": []
47
+ }
48
+ }
6
49
  }