@openpen/module-api 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## [1.1.1](https://github.com/openpen-platform/openpen/compare/v1.1.0...v1.1.1) (2026-05-19)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **release-please:** correct per-package CHANGELOG path nesting ([d151e9d](https://github.com/openpen-platform/openpen/commit/d151e9def2fa82234b1c24a1d7f83f06988c0e36))
9
+
10
+ ## [1.1.0](https://github.com/openpen-platform/openpen/compare/v1.0.0...v1.1.0) (2026-05-19)
11
+
12
+
13
+ ### Features
14
+
15
+ * **cursors:** DOM overlay cursor system + plugin SDK doc gaps ([2f77192](https://github.com/openpen-platform/openpen/commit/2f771927fb94586b0ceef58e07fcb5220ed40c02))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **control-bar:** pause auto-collapse timer while any dialog is open ([#1](https://github.com/openpen-platform/openpen/issues/1)) ([add84ff](https://github.com/openpen-platform/openpen/commit/add84ff89f1ad5135c7371fd7d4bfd64b1ef2395))
21
+
22
+ ## 1.0.0 (2026-05-14)
23
+
24
+
25
+ ### Features
26
+
27
+ * initial open source release ([f58abd7](https://github.com/openpen-platform/openpen/commit/f58abd7c4f3e12be9c88cdad23c61c82f9db7a7f))
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * **settings:** persist single Color Mode change to disk ([db9e6dd](https://github.com/openpen-platform/openpen/commit/db9e6dda0b9e584f349fb31da4349bdfaec0a84a))
33
+
34
+ ## Changelog
package/README.md CHANGED
@@ -21,8 +21,11 @@ npm install @openpen/module-api zod
21
21
  import { defineModule, z } from '@openpen/module-api'
22
22
 
23
23
  export default defineModule({
24
- id: 'my-module',
25
- name: { en: 'My Module', 'zh-TW': '我的模組' },
24
+ id: '@scope/my-module',
25
+ version: '0.1.0',
26
+ metadata: {
27
+ name: { en: 'My Module', 'zh-Hant': '我的模組' },
28
+ },
26
29
  settingsSchema: z.object({
27
30
  enabled: z.boolean().default(true),
28
31
  }),
@@ -44,9 +47,13 @@ export default defineModule({
44
47
  | Method | Description |
45
48
  |--------|-------------|
46
49
  | `ctx.getSettings<T>()` | Read this module's parsed settings (requires `settingsSchema` to be declared) |
50
+ | `ctx.updateSettings(patch)` | Persist a partial update to this module's settings |
51
+ | `ctx.onSettingsChange<T>(cb)` | Subscribe to settings changes; returns an unsubscribe function |
47
52
  | `ctx.callMain(action, payload?)` | Call this module's main-side handler |
48
53
  | `ctx.onDispose(fn)` | Register a cleanup callback (called in reverse order when the module is unloaded) |
49
54
  | `ctx.notify(payload)` | Show a short-lived toast in the overlay window; returns `NotifyHandle` for early `dismiss()` |
55
+ | `ctx.t(key, params?)` | Resolve an i18n key against this module's `contributes.locales` for the current host locale |
56
+ | `ctx.locale` | The host's current locale (e.g. `'en'`, `'zh-Hant'`) |
50
57
 
51
58
  ```typescript
52
59
  import { defineModule } from '@openpen/module-api'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpen/module-api",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "OpenPen Module API — public contract for built-in modules and third-party plugins",
5
5
  "author": "navishachiku <navishachiku@gmail.com>",
6
6
  "homepage": "https://github.com/openpen-platform/openpen#readme",
@@ -42,9 +42,15 @@
42
42
  "node": ">=20",
43
43
  "npm": ">=9"
44
44
  },
45
- "keywords": ["openpen", "plugin", "module", "sdk"],
45
+ "keywords": [
46
+ "openpen",
47
+ "plugin",
48
+ "module",
49
+ "sdk"
50
+ ],
46
51
  "dependencies": {
47
- "reka-ui": "~2.9.6"
52
+ "dompurify": "^3.4.5",
53
+ "reka-ui": "~2.9.7"
48
54
  },
49
55
  "peerDependencies": {
50
56
  "vue": "^3.5.0",
package/src/cursors.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Cursor compilation + sanitisation helpers shared by host runtime and
3
+ * plugin SDK.
4
+ *
5
+ * Three closure layers gate every cursor entry before it reaches
6
+ * `v-html`: R1–R12 shape validation here, DOMPurify content sanitisation
7
+ * inside compileCursor, and a deep-clone + freeze in the contribution
8
+ * store. Removing any one of them re-opens an attack surface the others
9
+ * cannot close.
10
+ */
11
+
12
+ import DOMPurify from 'dompurify'
13
+ import type {
14
+ CursorContribution,
15
+ CursorSpec,
16
+ Hotspot,
17
+ PngCursorSpec,
18
+ SvgCursorSpec,
19
+ } from './types/contributions'
20
+
21
+ export interface CompiledCursor {
22
+ /** Sanitised SVG / HTML fragment to mount as `innerHTML` on the cursor element. */
23
+ svgMarkup: string
24
+ /** Hotspot offset, in CSS pixels relative to the markup's top-left. */
25
+ hotspot: Hotspot
26
+ /** CSS keyword fallback retained for legacy callers. */
27
+ fallbackCssKeyword: string
28
+ }
29
+
30
+ export interface CursorResolutionContext {
31
+ /**
32
+ * Hostname-encoded plugin id (`@acme/x` → `acme.x`) used to assemble
33
+ * `openpen-plugin://<hostname>/<path>` URLs for relative-path specs.
34
+ * Undefined for built-in modules — path-form specs without a hostname
35
+ * resolve to `null` (host falls back to its default cursor).
36
+ */
37
+ pluginHostname?: string
38
+ /** Injection point for tests; defaults to global `fetch`. */
39
+ fetchImpl?: typeof fetch
40
+ }
41
+
42
+ /**
43
+ * W3C CSS UI Level 3 cursor keywords. The legacy `cursor: string` form
44
+ * MUST match one of these (case-insensitive after trim); everything else
45
+ * is rejected by R2 / R12.
46
+ */
47
+ export const SAFE_CURSOR_KEYWORDS: ReadonlySet<string> = new Set([
48
+ 'auto', 'default', 'none',
49
+ 'context-menu', 'help', 'pointer', 'progress', 'wait',
50
+ 'cell', 'crosshair', 'text', 'vertical-text',
51
+ 'alias', 'copy', 'move', 'no-drop', 'not-allowed', 'grab', 'grabbing',
52
+ 'e-resize', 'n-resize', 'ne-resize', 'nw-resize',
53
+ 's-resize', 'se-resize', 'sw-resize', 'w-resize',
54
+ 'ew-resize', 'ns-resize', 'nesw-resize', 'nwse-resize',
55
+ 'col-resize', 'row-resize', 'all-scroll',
56
+ 'zoom-in', 'zoom-out',
57
+ ])
58
+
59
+ const FORBIDDEN_CSS_LOAD_PATTERN = /url\s*\(|image-set\s*\(|-webkit-image-set\s*\(/i
60
+
61
+ const DEFAULT_FALLBACK_KEYWORD = 'crosshair'
62
+
63
+ export function isSafeRelativePath(p: unknown): p is string {
64
+ if (typeof p !== 'string' || p.length === 0) return false
65
+ // Bare `:` catches scheme prefixes without `://` (e.g. `data:`).
66
+ if (p.includes(':')) return false
67
+ if (p.startsWith('/')) return false
68
+ for (const seg of p.split('/')) {
69
+ if (seg === '' || seg === '..') return false
70
+ }
71
+ return true
72
+ }
73
+
74
+ export function pluginHostname(moduleId: string): string {
75
+ return moduleId.replace(/^@/, '').replace(/\//g, '.')
76
+ }
77
+
78
+ export function pluginAssetUrl(moduleId: string, relativePath: string): string {
79
+ if (!isSafeRelativePath(relativePath)) {
80
+ throw new Error(`Unsafe asset path: ${relativePath}`)
81
+ }
82
+ return `openpen-plugin://${pluginHostname(moduleId)}/${relativePath}`
83
+ }
84
+
85
+ /**
86
+ * Sanitise SVG markup before it reaches `v-html`. Rationale for each
87
+ * flag: `USE_PROFILES: { svg: true }` covers script tags, on* handlers,
88
+ * and javascript: hrefs; `FORBID_TAGS: ['foreignObject']` closes the
89
+ * mXSS escape hatch the SVG profile alone does not; turning off
90
+ * `ALLOW_UNKNOWN_PROTOCOLS` blocks `<use href="data:...">` and friends.
91
+ */
92
+ export function sanitizeSvgMarkup(raw: string): string {
93
+ return DOMPurify.sanitize(raw, {
94
+ USE_PROFILES: { svg: true },
95
+ FORBID_TAGS: ['foreignObject'],
96
+ // Cursor SVGs use presentation attributes (fill/stroke/etc.), never
97
+ // inline `style`. Forbidding it strips legacy `expression()` CSS
98
+ // smuggling vectors (S7) without touching anything we draw.
99
+ FORBID_ATTR: ['style'],
100
+ ALLOW_UNKNOWN_PROTOCOLS: false,
101
+ })
102
+ }
103
+
104
+ function resolveHotspot(spec: SvgCursorSpec | PngCursorSpec): Hotspot {
105
+ const h = spec.hotspot
106
+ if (!h || typeof h !== 'object') return { x: 0, y: 0 }
107
+ const x = typeof h.x === 'number' && Number.isFinite(h.x) ? h.x : 0
108
+ const y = typeof h.y === 'number' && Number.isFinite(h.y) ? h.y : 0
109
+ return { x, y }
110
+ }
111
+
112
+ function resolveFallback(spec: SvgCursorSpec | PngCursorSpec): string {
113
+ return typeof spec.fallback === 'string' && spec.fallback.length > 0
114
+ ? spec.fallback
115
+ : DEFAULT_FALLBACK_KEYWORD
116
+ }
117
+
118
+ function escapeHtmlAttr(s: string): string {
119
+ return s
120
+ .replace(/&/g, '&amp;')
121
+ .replace(/"/g, '&quot;')
122
+ .replace(/</g, '&lt;')
123
+ .replace(/>/g, '&gt;')
124
+ }
125
+
126
+ /**
127
+ * Convert a frozen, shape-validated CursorContribution.cursor into render
128
+ * data ready for `v-html`. Returns `null` for legacy keyword strings
129
+ * (caller falls back to the host default cursor) and for any spec the
130
+ * sanitiser empties out completely.
131
+ *
132
+ * Async because path-form SVG specs are fetched through `openpen-plugin://`
133
+ * at compile time; inline + PNG branches resolve synchronously inside the
134
+ * returned Promise.
135
+ */
136
+ export async function compileCursor(
137
+ spec: CursorSpec,
138
+ context: CursorResolutionContext = {},
139
+ ): Promise<CompiledCursor | null> {
140
+ if (typeof spec === 'string') return null
141
+ if (!spec || typeof spec !== 'object') return null
142
+
143
+ if ('svg' in spec && typeof spec.svg === 'string') {
144
+ const hotspot = resolveHotspot(spec)
145
+ const fallbackCssKeyword = resolveFallback(spec)
146
+ const trimmed = spec.svg.trim()
147
+
148
+ if (trimmed.startsWith('<')) {
149
+ const sanitised = sanitizeSvgMarkup(trimmed)
150
+ if (!sanitised) return null
151
+ return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
152
+ }
153
+
154
+ if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
155
+ const url = `openpen-plugin://${context.pluginHostname}/${trimmed}`
156
+ const fetchImpl = context.fetchImpl
157
+ ?? (typeof fetch !== 'undefined' ? fetch.bind(globalThis) : undefined)
158
+ if (!fetchImpl) return null
159
+
160
+ try {
161
+ const resp = await fetchImpl(url)
162
+ if (!resp.ok) return null
163
+ const raw = await resp.text()
164
+ const sanitised = sanitizeSvgMarkup(raw)
165
+ if (!sanitised) return null
166
+ return { svgMarkup: sanitised, hotspot, fallbackCssKeyword }
167
+ } catch {
168
+ return null
169
+ }
170
+ }
171
+
172
+ if ('png' in spec && typeof spec.png === 'string') {
173
+ const hotspot = resolveHotspot(spec)
174
+ const fallbackCssKeyword = resolveFallback(spec)
175
+ const trimmed = spec.png.trim()
176
+ if (!context.pluginHostname || !isSafeRelativePath(trimmed)) return null
177
+ const url = escapeHtmlAttr(`openpen-plugin://${context.pluginHostname}/${trimmed}`)
178
+ const svgMarkup =
179
+ `<img src="${url}" alt="" style="display:block;max-width:64px;max-height:64px;" />`
180
+ return { svgMarkup, hotspot, fallbackCssKeyword }
181
+ }
182
+
183
+ return null
184
+ }
185
+
186
+ export type CursorSanitizeRule =
187
+ | 'R1' | 'R2' | 'R3' | 'R4' | 'R5' | 'R6'
188
+ | 'R7' | 'R8' | 'R9' | 'R10' | 'R11' | 'R12'
189
+
190
+ export interface CursorSanitizeDiagnostic {
191
+ /** Contribution id, or `'<unknown>'` if `id` was missing / invalid. */
192
+ id: string
193
+ rule: CursorSanitizeRule
194
+ reason: string
195
+ }
196
+
197
+ export interface CursorSanitizeOptions {
198
+ /**
199
+ * Built-in modules (host bundle) cannot use path forms because they
200
+ * have no `openpen-plugin://<hostname>` namespace. R7 / R10 reject
201
+ * such contributions for built-ins.
202
+ */
203
+ isBuiltin?: boolean
204
+ }
205
+
206
+ export interface CursorSanitizeResult {
207
+ cleaned: CursorContribution[]
208
+ diagnostics: CursorSanitizeDiagnostic[]
209
+ }
210
+
211
+ /**
212
+ * Shape-validate raw cursor contributions before they reach the
213
+ * contribution-store. This is the R1–R12 gate; SVG markup content is
214
+ * sanitised later inside {@link compileCursor} via DOMPurify.
215
+ */
216
+ export function sanitizeCursorContributions(
217
+ cursors: unknown,
218
+ options: CursorSanitizeOptions = {},
219
+ ): CursorSanitizeResult {
220
+ const cleaned: CursorContribution[] = []
221
+ const diagnostics: CursorSanitizeDiagnostic[] = []
222
+
223
+ if (!Array.isArray(cursors)) return { cleaned, diagnostics }
224
+
225
+ for (const raw of cursors) {
226
+ if (raw === null || typeof raw !== 'object') {
227
+ diagnostics.push({ id: '<unknown>', rule: 'R1', reason: 'contribution is not an object' })
228
+ continue
229
+ }
230
+
231
+ const entry = raw as { id?: unknown; cursor?: unknown }
232
+ const id = typeof entry.id === 'string' && entry.id.length > 0 ? entry.id : '<unknown>'
233
+
234
+ if (id === '<unknown>') {
235
+ diagnostics.push({ id, rule: 'R1', reason: 'missing or empty `id`' })
236
+ continue
237
+ }
238
+
239
+ const { cursor } = entry
240
+
241
+ if (typeof cursor === 'string') {
242
+ const trimmedLower = cursor.trim().toLowerCase()
243
+ if (FORBIDDEN_CSS_LOAD_PATTERN.test(trimmedLower)) {
244
+ diagnostics.push({
245
+ id,
246
+ rule: 'R12',
247
+ reason: 'string contains url() / image-set() / -webkit-image-set(); loads external resource',
248
+ })
249
+ continue
250
+ }
251
+ if (!SAFE_CURSOR_KEYWORDS.has(trimmedLower)) {
252
+ diagnostics.push({
253
+ id,
254
+ rule: 'R2',
255
+ reason: `string '${trimmedLower}' is not in the safe CSS cursor keyword whitelist`,
256
+ })
257
+ continue
258
+ }
259
+ cleaned.push({ id, cursor: trimmedLower })
260
+ continue
261
+ }
262
+
263
+ if (cursor === null || typeof cursor !== 'object' || Array.isArray(cursor)) {
264
+ diagnostics.push({ id, rule: 'R1', reason: 'cursor field is neither string nor plain object' })
265
+ continue
266
+ }
267
+
268
+ const cursorObj = cursor as Record<string, unknown>
269
+ const hasSvg = Object.prototype.hasOwnProperty.call(cursorObj, 'svg')
270
+ const hasPng = Object.prototype.hasOwnProperty.call(cursorObj, 'png')
271
+
272
+ if (!hasSvg && !hasPng) {
273
+ diagnostics.push({ id, rule: 'R3', reason: 'cursor object has neither `svg` nor `png` key' })
274
+ continue
275
+ }
276
+ if (hasSvg && hasPng) {
277
+ diagnostics.push({ id, rule: 'R4', reason: 'cursor object has both `svg` and `png` keys (ambiguous)' })
278
+ continue
279
+ }
280
+
281
+ if (hasSvg) {
282
+ const svg = cursorObj.svg
283
+ if (typeof svg !== 'string') {
284
+ diagnostics.push({ id, rule: 'R5', reason: '`svg` value is not a string' })
285
+ continue
286
+ }
287
+ const trimmed = svg.trim()
288
+ const isInline = trimmed.startsWith('<')
289
+ if (!isInline) {
290
+ if (!isSafeRelativePath(trimmed)) {
291
+ diagnostics.push({
292
+ id,
293
+ rule: 'R6',
294
+ reason: '`svg` value is non-inline and not a safe relative path',
295
+ })
296
+ continue
297
+ }
298
+ if (options.isBuiltin) {
299
+ diagnostics.push({
300
+ id,
301
+ rule: 'R7',
302
+ reason: 'built-in modules cannot use path-form `svg` (no openpen-plugin hostname)',
303
+ })
304
+ continue
305
+ }
306
+ }
307
+ }
308
+
309
+ if (hasPng) {
310
+ const png = cursorObj.png
311
+ if (typeof png !== 'string') {
312
+ diagnostics.push({ id, rule: 'R8', reason: '`png` value is not a string' })
313
+ continue
314
+ }
315
+ if (!isSafeRelativePath(png.trim())) {
316
+ diagnostics.push({
317
+ id,
318
+ rule: 'R9',
319
+ reason: '`png` value is not a safe relative path',
320
+ })
321
+ continue
322
+ }
323
+ if (options.isBuiltin) {
324
+ diagnostics.push({
325
+ id,
326
+ rule: 'R10',
327
+ reason: 'built-in modules cannot use `png` (no openpen-plugin hostname)',
328
+ })
329
+ continue
330
+ }
331
+ }
332
+
333
+ if (cursorObj.hotspot !== undefined) {
334
+ const h = cursorObj.hotspot as Record<string, unknown> | null
335
+ const valid =
336
+ h !== null &&
337
+ typeof h === 'object' &&
338
+ typeof h.x === 'number' && Number.isFinite(h.x) && h.x >= 0 &&
339
+ typeof h.y === 'number' && Number.isFinite(h.y) && h.y >= 0
340
+ if (!valid) {
341
+ diagnostics.push({
342
+ id,
343
+ rule: 'R11',
344
+ reason: '`hotspot` must be `{ x: number ≥ 0, y: number ≥ 0 }`',
345
+ })
346
+ continue
347
+ }
348
+ }
349
+
350
+ // Output a strictly-allowlisted clone — only known primitive fields
351
+ // pass through. Anything else (function / symbol / getter / circular
352
+ // reference attached as an extra key) is dropped here so the
353
+ // downstream `structuredClone` in the contribution-store cannot
354
+ // throw and trigger a module-wide rollback.
355
+ const cleanedCursor: SvgCursorSpec | PngCursorSpec = hasSvg
356
+ ? { svg: (cursorObj.svg as string).trim() }
357
+ : { png: (cursorObj.png as string).trim() }
358
+ if (cursorObj.hotspot !== undefined) {
359
+ const h = cursorObj.hotspot as { x: number; y: number }
360
+ cleanedCursor.hotspot = { x: h.x, y: h.y }
361
+ }
362
+ if (typeof cursorObj.fallback === 'string') {
363
+ cleanedCursor.fallback = cursorObj.fallback
364
+ }
365
+ cleaned.push({ id, cursor: cleanedCursor })
366
+ }
367
+
368
+ return { cleaned, diagnostics }
369
+ }
package/src/index.ts CHANGED
@@ -63,6 +63,10 @@ export type {
63
63
  SettingsTabContribution,
64
64
  SettingsPanelContribution,
65
65
  CursorContribution,
66
+ CursorSpec,
67
+ SvgCursorSpec,
68
+ PngCursorSpec,
69
+ Hotspot,
66
70
  StatusContribution,
67
71
  ModalContribution,
68
72
  TrayMenuContribution,
@@ -89,9 +93,30 @@ export {
89
93
  CONTROL_BAR_ANIMATING_KEY,
90
94
  HOST_DIALOG_OPEN_COUNT_KEY,
91
95
  POPOVER_PLACEMENT_HINT_KEY,
96
+ ACTIVE_TOOL_KEY,
92
97
  } from './inject-keys'
93
98
  export type { StrokeStyleContext, SnapEdge, ModalManager, PopoverPlacementHint } from './inject-keys'
94
99
 
100
+ // Cursor compilation + sanitisation (used by host runtime; helpers
101
+ // re-exported so plugins can validate cursor specs in their own tests).
102
+ export {
103
+ compileCursor,
104
+ sanitizeSvgMarkup,
105
+ sanitizeCursorContributions,
106
+ isSafeRelativePath,
107
+ pluginHostname,
108
+ pluginAssetUrl,
109
+ SAFE_CURSOR_KEYWORDS,
110
+ } from './cursors'
111
+ export type {
112
+ CompiledCursor,
113
+ CursorResolutionContext,
114
+ CursorSanitizeRule,
115
+ CursorSanitizeDiagnostic,
116
+ CursorSanitizeOptions,
117
+ CursorSanitizeResult,
118
+ } from './cursors'
119
+
95
120
  // Re-export zod so modules don't need a separate import path.
96
121
  export { z } from 'zod'
97
122
 
@@ -141,3 +141,14 @@ export type PopoverPlacementHint = 'top' | 'bottom' | 'left' | 'right'
141
141
 
142
142
  export const POPOVER_PLACEMENT_HINT_KEY: InjectionKey<Readonly<Ref<PopoverPlacementHint>>> =
143
143
  Symbol('openpen:popoverPlacementHint')
144
+
145
+ // ── Active Tool ───────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * The currently active tool id. Provided by ControlBar.vue as `readonly(activeToolId)`.
149
+ * Module tool-button components inject this for reactive active-state without
150
+ * depending on the event-bus timing (event bus is fire-and-forget; this ref is
151
+ * always current regardless of when the component mounts).
152
+ */
153
+ export const ACTIVE_TOOL_KEY: InjectionKey<Readonly<Ref<string>>> =
154
+ Symbol('openpen:activeTool')
@@ -161,10 +161,45 @@ export interface SettingsPanelContribution {
161
161
  component: Component
162
162
  }
163
163
 
164
+ /** Hotspot offset in CSS pixels relative to the cursor image's top-left. */
165
+ export interface Hotspot {
166
+ x: number
167
+ y: number
168
+ }
169
+
170
+ /**
171
+ * Vector cursor: inline SVG markup OR a plugin-internal relative path
172
+ * to a `.svg` file. URL-shaped strings, absolute paths, and `..` segments
173
+ * are rejected at contribution-validation time.
174
+ */
175
+ export interface SvgCursorSpec {
176
+ svg: string
177
+ hotspot?: Hotspot
178
+ /** CSS keyword fallback for legacy / non-overlay contexts. Defaults to `'crosshair'`. */
179
+ fallback?: string
180
+ }
181
+
182
+ /**
183
+ * Raster cursor: plugin-internal relative path to a `.png` file. Inline
184
+ * form is not supported — PNG payload comes through `openpen-plugin://`.
185
+ */
186
+ export interface PngCursorSpec {
187
+ png: string
188
+ hotspot?: Hotspot
189
+ fallback?: string
190
+ }
191
+
192
+ /**
193
+ * Cursor specification. The legacy `string` form must be one of the
194
+ * whitelisted CSS keywords (see `SAFE_CURSOR_KEYWORDS` in cursors.ts);
195
+ * any string containing `url(` / `image-set(` / `-webkit-image-set(` is
196
+ * rejected to keep the asset trust boundary closed.
197
+ */
198
+ export type CursorSpec = string | SvgCursorSpec | PngCursorSpec
199
+
164
200
  export interface CursorContribution {
165
201
  id: string
166
- /** CSS cursor value or a data URL. */
167
- cursor: string
202
+ cursor: CursorSpec
168
203
  }
169
204
 
170
205
  export interface StatusContribution {
@@ -21,6 +21,7 @@ interface Option {
21
21
  value: string
22
22
  label: string
23
23
  icon?: string
24
+ testid?: string
24
25
  }
25
26
 
26
27
  defineProps<{
@@ -60,6 +61,7 @@ function onValueChange(val: unknown) {
60
61
  <button
61
62
  class="app-seg-btn"
62
63
  :class="{ active: modelValue === opt.value }"
64
+ :data-testid="opt.testid"
63
65
  >
64
66
  <span
65
67
  v-if="opt.icon"
@@ -18,7 +18,7 @@
18
18
  * falls back gracefully (no mutual exclusion, no animating-guard) — it still
19
19
  * renders and resolves correctly, just without those host integrations.
20
20
  */
21
- import { computed, nextTick, provide, ref, watch } from 'vue'
21
+ import { computed, inject, nextTick, provide, ref, watch } from 'vue'
22
22
  import AppDialog from './AppDialog.vue'
23
23
  // _activeRequest MUST come from the package barrel — see AppPopover.vue header
24
24
  // comment. The runtime build externalises '@openpen/module-api/uikit' so host
@@ -27,11 +27,17 @@ import AppDialog from './AppDialog.vue'
27
27
  // callers to write to a different ref than DialogHost watches (dialog never
28
28
  // opens in production).
29
29
  import { _activeRequest, _resolveActive } from '@openpen/module-api/uikit'
30
+ import { HOST_DIALOG_OPEN_COUNT_KEY } from '@openpen/module-api'
30
31
  import { DIALOG_PLUGIN_HANDLE_KEY } from '../composables/dialog-internal-keys'
31
32
  import type { DialogPluginComponentHandle } from '../composables/useDialogPluginComponent'
32
33
 
33
34
  const MODAL_ID = '__openpen_imperative_dialog__'
34
35
 
36
+ // Increment/decrement the host-level dialog-open counter so ControlBar can
37
+ // pause the auto-collapse timer while any imperative dialog is visible.
38
+ // Falls back to a local ref when not provided (settings window has no host bar).
39
+ const dialogOpenCount = inject(HOST_DIALOG_OPEN_COUNT_KEY, ref(0))
40
+
35
41
  // Whether the dialog is open: true when there is an active request.
36
42
  const isOpen = ref(false)
37
43
 
@@ -47,6 +53,9 @@ watch(_activeRequest, (req) => {
47
53
  if (req.kind === 'prompt') {
48
54
  promptValue.value = req.opts.defaultValue ?? ''
49
55
  }
56
+ if (!isOpen.value) {
57
+ dialogOpenCount.value++
58
+ }
50
59
  isOpen.value = true
51
60
  if (req.kind === 'prompt') {
52
61
  nextTick(() => {
@@ -54,6 +63,9 @@ watch(_activeRequest, (req) => {
54
63
  })
55
64
  }
56
65
  } else {
66
+ if (isOpen.value) {
67
+ dialogOpenCount.value = Math.max(0, dialogOpenCount.value - 1)
68
+ }
57
69
  isOpen.value = false
58
70
  }
59
71
  })