@openpen/module-api 1.1.0 → 1.2.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js +245 -0
  3. package/dist/_chunks/AppButtonDropdown.vue_vue_type_style_index_0_lang-DBVFUpxc.js.map +1 -0
  4. package/dist/_chunks/inject-keys-CRXCpN-M.js +196 -0
  5. package/dist/_chunks/inject-keys-CRXCpN-M.js.map +1 -0
  6. package/dist/_chunks/module-context-registry-Cn1IWYu9.js +16 -0
  7. package/dist/_chunks/module-context-registry-Cn1IWYu9.js.map +1 -0
  8. package/dist/_chunks/passthrough-DKU7JYuQ.js +6 -0
  9. package/dist/_chunks/passthrough-DKU7JYuQ.js.map +1 -0
  10. package/dist/cursors.d.ts +83 -0
  11. package/dist/define-module.d.ts +17 -0
  12. package/dist/host/color-utils.d.ts +11 -0
  13. package/{src/host/event-bus.ts → dist/host/event-bus.d.ts} +7 -5
  14. package/dist/host/host-commands.d.ts +1 -0
  15. package/dist/host/index.d.ts +24 -0
  16. package/dist/host/index.js +49 -0
  17. package/dist/host/index.js.map +1 -0
  18. package/{src/host/module-context-registry.ts → dist/host/module-context-registry.d.ts} +5 -19
  19. package/dist/host/passthrough.d.ts +2 -0
  20. package/dist/host/popup-anchor.d.ts +3 -0
  21. package/dist/host/registry.d.ts +39 -0
  22. package/dist/host/registry.js +37 -0
  23. package/dist/host/registry.js.map +1 -0
  24. package/dist/host/slot-entries.d.ts +2 -0
  25. package/dist/host/stroke-store.d.ts +5 -0
  26. package/dist/host/stroke-style.d.ts +3 -0
  27. package/dist/host/types.d.ts +164 -0
  28. package/dist/index.d.ts +28 -0
  29. package/dist/index.js +349 -0
  30. package/dist/index.js.map +1 -0
  31. package/{src/inject-keys.ts → dist/inject-keys.d.ts} +37 -66
  32. package/{src/locale.ts → dist/locale.d.ts} +3 -22
  33. package/dist/slots.d.ts +57 -0
  34. package/dist/types/contributions.d.ts +288 -0
  35. package/dist/types/control-bar-layout.d.ts +227 -0
  36. package/dist/types/module.d.ts +208 -0
  37. package/{src/types/notification.ts → dist/types/notification.d.ts} +12 -14
  38. package/dist/types/tool.d.ts +77 -0
  39. package/dist/uikit/components/AppBanner.vue.d.ts +34 -0
  40. package/dist/uikit/components/AppButton.vue.d.ts +30 -0
  41. package/dist/uikit/components/AppButtonDropdown.vue.d.ts +52 -0
  42. package/dist/uikit/components/AppDialog.vue.d.ts +53 -0
  43. package/dist/uikit/components/AppPopover.vue.d.ts +43 -0
  44. package/dist/uikit/components/AppSegmented.vue.d.ts +19 -0
  45. package/dist/uikit/components/AppSelect.vue.d.ts +26 -0
  46. package/dist/uikit/components/AppSlider.vue.d.ts +43 -0
  47. package/dist/uikit/components/AppTabs.vue.d.ts +35 -0
  48. package/dist/uikit/components/AppToggle.vue.d.ts +13 -0
  49. package/dist/uikit/components/AppTooltip.vue.d.ts +27 -0
  50. package/dist/uikit/components/DialogHost.vue.d.ts +3 -0
  51. package/{src/uikit/composables/dialog-internal-keys.ts → dist/uikit/composables/dialog-internal-keys.d.ts} +3 -5
  52. package/dist/uikit/composables/useDialog.d.ts +399 -0
  53. package/dist/uikit/composables/useDialogPluginComponent.d.ts +9 -0
  54. package/dist/uikit/index.d.ts +42 -0
  55. package/dist/uikit/index.js +884 -0
  56. package/dist/uikit/index.js.map +1 -0
  57. package/{src/uikit/internal.ts → dist/uikit/internal.d.ts} +1 -1
  58. package/dist/uikit/internal.js +157 -0
  59. package/dist/uikit/internal.js.map +1 -0
  60. package/dist/uikit/module-api.css +851 -0
  61. package/dist/uikit/primitives.d.ts +42 -0
  62. package/{src/use-module-context.ts → dist/use-module-context.d.ts} +2 -15
  63. package/{src/validation.ts → dist/validation.d.ts} +2 -6
  64. package/package.json +26 -12
  65. package/packages/module-api/CHANGELOG.md +0 -27
  66. package/src/cursors.ts +0 -369
  67. package/src/define-module.ts +0 -55
  68. package/src/host/color-utils.ts +0 -18
  69. package/src/host/host-commands.ts +0 -9
  70. package/src/host/index.ts +0 -53
  71. package/src/host/passthrough.ts +0 -10
  72. package/src/host/popup-anchor.ts +0 -14
  73. package/src/host/slot-entries.ts +0 -9
  74. package/src/host/stroke-store.ts +0 -20
  75. package/src/host/stroke-style.ts +0 -15
  76. package/src/index.ts +0 -131
  77. package/src/slots.ts +0 -135
  78. package/src/types/contributions.ts +0 -333
  79. package/src/types/control-bar-layout.ts +0 -174
  80. package/src/types/module.ts +0 -225
  81. package/src/types/tool.ts +0 -82
  82. package/src/uikit/components/.gitkeep +0 -0
  83. package/src/uikit/components/AppBanner.vue +0 -131
  84. package/src/uikit/components/AppButton.vue +0 -135
  85. package/src/uikit/components/AppDialog.vue +0 -393
  86. package/src/uikit/components/AppPopover.vue +0 -268
  87. package/src/uikit/components/AppSegmented.vue +0 -128
  88. package/src/uikit/components/AppSelect.vue +0 -235
  89. package/src/uikit/components/AppSlider.vue +0 -204
  90. package/src/uikit/components/AppTabs.vue +0 -163
  91. package/src/uikit/components/AppToggle.vue +0 -96
  92. package/src/uikit/components/AppTooltip.vue +0 -118
  93. package/src/uikit/components/DialogHost.vue +0 -272
  94. package/src/uikit/composables/useDialog.ts +0 -141
  95. package/src/uikit/composables/useDialogPluginComponent.ts +0 -27
  96. package/src/uikit/index.ts +0 -79
  97. package/src/uikit/primitives.ts +0 -150
  98. /package/{src → dist}/uikit/tokens.css +0 -0
@@ -1,333 +0,0 @@
1
- import type { Component } from 'vue'
2
- import type { LocaleMap } from '../locale'
3
- import type { Stroke, Tool } from './tool'
4
- import type { ControlBarContribution } from './control-bar-layout'
5
-
6
- export type { ControlBarContribution }
7
-
8
- /**
9
- * The shape of `OpenPenModule.contributes`. Every field is optional;
10
- * `defineModule` requires at least one field to be present and rejects
11
- * any unknown field at module-author boundary time.
12
- *
13
- * Each field corresponds to a slot in {@link import('../slots').ALL_SLOTS}.
14
- * The mapping from camelCase field name to dotted slot id lives in
15
- * `CONTRIBUTION_KEY_TO_SLOT_ID`.
16
- */
17
- export interface ModuleContributions {
18
- // ── Canvas ──────────────────────────────────────────────────────────────
19
- tools?: ToolContribution[]
20
- shapes?: ShapeContribution[]
21
- strokeStyle?: StrokeStyleContribution
22
- historyCommands?: HistoryCommandContribution[]
23
- backgroundLayers?: CanvasLayerContribution[]
24
- overlayLayers?: CanvasLayerContribution[]
25
- htmlOverlays?: HtmlOverlayContribution[]
26
- /** ⏳ Reserved slot — accepts registrations, adapter not yet wired. */
27
- strokeTransformers?: StrokeTransformerContribution[]
28
-
29
- // ── UI ──────────────────────────────────────────────────────────────────
30
- controlBar?: ControlBarContribution[]
31
- settingsTabs?: SettingsTabContribution[]
32
- settingsPanels?: SettingsPanelContribution[]
33
- cursors?: CursorContribution[]
34
- status?: StatusContribution[]
35
- modals?: ModalContribution[]
36
- trayMenu?: TrayMenuContribution[]
37
- /** ⏳ Reserved slot — accepts registrations, adapter not yet wired. */
38
- contextMenu?: ContextMenuContribution[]
39
- /** ⏳ Reserved slot — accepts registrations, adapter not yet wired. */
40
- themeTokens?: ThemeTokenContribution
41
-
42
- // ── System ──────────────────────────────────────────────────────────────
43
- shortcuts?: ShortcutContribution[]
44
- windowBehaviors?: WindowBehaviorContribution[]
45
- locales?: LocaleContribution
46
- events?: EventSubscriptionContribution[]
47
- lifecycle?: LifecycleContribution
48
- storage?: StorageContribution
49
- mainHandlers?: MainHandlerContribution
50
- /** ⏳ Reserved slot — accepts registrations, adapter not yet wired. */
51
- fileDrop?: FileDropContribution[]
52
- }
53
-
54
- // ════════════════════════════════════════════════════════════════════════
55
- // Below: per-slot contribution shapes. These are the locked public-facing
56
- // names referenced by docs and the validator; their internals can extend
57
- // without breaking modules.
58
- // ════════════════════════════════════════════════════════════════════════
59
-
60
- /**
61
- * Drawing tool driven by pointer events. Implements the full `Tool`
62
- * interface (onPointerDown / Move / Up) plus optional metadata for
63
- * the toolbar UI and an optional `renderStroke` for tools whose
64
- * strokes need custom redraw semantics (shapes, erasers, anything
65
- * beyond a simple polyline).
66
- */
67
- export interface ToolContribution extends Tool {
68
- id: string
69
- label?: string | LocaleMap
70
- /**
71
- * SVG markup string for this tool's icon in the control-bar button.
72
- *
73
- * The host renders tool icons via `v-html`, so only raw SVG strings are
74
- * accepted (not Vue components or CSS class names). Recommended size is
75
- * `width="16" height="16"` with `stroke="currentColor"` so the icon
76
- * inherits the button's colour automatically.
77
- *
78
- * @example
79
- * icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 17c3-3 6-6 9-3s6 0 9-3"/></svg>'
80
- *
81
- * Note: built-in tools (freehand, line, eraser) use their own Vue button
82
- * components rather than this field — `icon` is the lightweight path for
83
- * plugin-contributed tools that do not need a custom button component.
84
- */
85
- icon?: string
86
- /**
87
- * Custom redraw renderer. When omitted, canvas-engine falls back
88
- * to a polyline render through `stroke.points` — sufficient for
89
- * freehand- and line-style tools.
90
- */
91
- renderStroke?: (canvasCtx: CanvasRenderingContext2D, stroke: Stroke) => void
92
- }
93
-
94
- /** Shape primitive used by the shape tool. */
95
- export interface ShapeContribution {
96
- id: string
97
- label?: string | LocaleMap
98
- /**
99
- * Vue component or SVG string for the shape icon in control-bar / sub-panel.
100
- * Host renders via `<component :is="icon">`.
101
- * Component icons MAY accept an optional `filled?: boolean` prop —
102
- * the host passes the current fill-toggle state so icons can mirror it
103
- * (e.g. solid vs outline rendering). Plugin icons that ignore the prop
104
- * still work; they just won't reflect fill state visually.
105
- */
106
- icon?: Component | string
107
- draw: (...args: unknown[]) => void
108
- }
109
-
110
- /**
111
- * Stroke style writer. Modules declare which keys of the shared
112
- * stroke style store they write to; collision detection happens at
113
- * module-validator time.
114
- */
115
- export interface StrokeStyleContribution {
116
- /** Keys this module writes (e.g. `['lineWidth']`, `['color']`). */
117
- provides: string[]
118
- }
119
-
120
- /**
121
- * Undo/redo command handler for the `canvas.history.commands` slot.
122
- *
123
- * The host calls `undo` / `redo` with whatever `payload` was stored when the
124
- * command was pushed onto the history stack. The contributing module decides
125
- * the payload shape — the host treats it as `unknown` and passes it through
126
- * without inspection.
127
- */
128
- export interface HistoryCommandContribution {
129
- type: string
130
- undo: (payload: unknown) => void
131
- redo: (payload: unknown) => void
132
- }
133
-
134
- export interface CanvasLayerContribution {
135
- id: string
136
- /** Render priority within the layer; higher = drawn later (on top). */
137
- order?: number
138
- render: (ctx: CanvasRenderingContext2D) => void
139
- }
140
-
141
- export interface HtmlOverlayContribution {
142
- id: string
143
- component: Component
144
- }
145
-
146
- export interface StrokeTransformerContribution {
147
- id: string
148
- /** Receives a stroke, returns a transformed stroke. Slot ⏳ reserved. */
149
- transform: (stroke: unknown) => unknown
150
- }
151
-
152
- export interface SettingsTabContribution {
153
- id: string
154
- label: string | LocaleMap
155
- component: Component
156
- }
157
-
158
- export interface SettingsPanelContribution {
159
- id: string
160
- label: string | LocaleMap
161
- component: Component
162
- }
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
-
200
- export interface CursorContribution {
201
- id: string
202
- cursor: CursorSpec
203
- }
204
-
205
- export interface StatusContribution {
206
- id: string
207
- component: Component
208
- }
209
-
210
- export interface ModalContribution {
211
- id: string
212
- component: Component
213
- }
214
-
215
- export interface TrayMenuContribution {
216
- id: string
217
- label: string | LocaleMap
218
- click: () => void
219
- }
220
-
221
- export interface ContextMenuContribution {
222
- id: string
223
- label: string | LocaleMap
224
- /** Where the menu may appear; runtime filters by context at open time. */
225
- context: 'canvas' | 'toolbar' | 'tray'
226
- click: () => void
227
- }
228
-
229
- /**
230
- * CSS custom property contributions for the `ui.theme.tokens` slot.
231
- *
232
- * Keys are CSS custom property names (e.g. `'--my-plugin-accent'`).
233
- * Values are CSS color or length values (e.g. `'#ff6600'`, `'4px'`).
234
- * The host merges all contributions into `:root` at startup.
235
- */
236
- export type ThemeTokenContribution = Record<string, string>
237
-
238
- export interface ShortcutContribution {
239
- id: string
240
- /** Accelerator string; tinykeys-style or Electron globalShortcut format. */
241
- keys: string
242
- /** `'global'` registers OS-wide; `'drawing'` only when overlay is active. */
243
- scope: 'global' | 'drawing'
244
- handler: () => void
245
- /** Human-readable name. Omit to hide this shortcut from the user-visible shortcuts list. */
246
- label?: string | LocaleMap
247
- /** Optional secondary description shown below the label in the shortcuts settings panel. */
248
- sublabel?: string | LocaleMap
249
- /** When true, the user can rebind this key. The chosen key is persisted under `config.json → customShortcuts`. */
250
- userCustomizable?: boolean
251
- }
252
-
253
- export interface WindowBehaviorContribution {
254
- id: string
255
- /** Reserved — concrete behaviour API not yet defined. */
256
- [key: string]: unknown
257
- }
258
-
259
- /** Leaf or branch in a locale dictionary. Leaves must be strings. */
260
- type LocaleDictValue = string | LocaleDict
261
-
262
- /**
263
- * Recursive locale dictionary.
264
- * Leaves are translated strings; branches are nested sub-dictionaries.
265
- * Use the `interface` form (not `type`) to allow self-reference without
266
- * triggering TypeScript's circular-type restrictions.
267
- */
268
- interface LocaleDict {
269
- [key: string]: LocaleDictValue
270
- }
271
-
272
- /**
273
- * A locale dictionary keyed by BCP-47 tag (e.g. `'en'`, `'zh-Hant'`).
274
- *
275
- * Values are per-locale message dictionaries. Supports both flat and nested
276
- * layouts — vue-i18n treats `.` in ctx.t(key) as a nested path separator,
277
- * so keys like `'notif.ready'` must use a nested object dict
278
- * `{ notif: { ready: '...' } }`, not a flat key `{ 'notif.ready': '...' }`.
279
- *
280
- * Non-string leaves are rejected at compile time; use ctx.t() to resolve
281
- * keys to strings before passing them to runtime APIs.
282
- */
283
- export type LocaleContribution = Record<string, LocaleDict>
284
-
285
- /**
286
- * Subscription to a named domain event on the `system.events` slot.
287
- *
288
- * `event` must match the event name emitted by another module (or the host)
289
- * via `system.events`. The host routes each emitted event to all registered
290
- * handlers whose `event` string matches.
291
- */
292
- export interface EventSubscriptionContribution {
293
- event: string
294
- handler: (payload: unknown) => void
295
- }
296
-
297
- /**
298
- * App lifecycle hooks for the `system.lifecycle` slot.
299
- *
300
- * Call order: `setup` (from `OpenPenModule.setup`) → `onReady` (host fully
301
- * booted) → `onSuspend` (overlay hidden) → `onQuit` (app shutting down).
302
- * All hooks may return a Promise; the host awaits each before advancing.
303
- */
304
- export interface LifecycleContribution {
305
- onReady?: () => void | Promise<void>
306
- onSuspend?: () => void | Promise<void>
307
- onQuit?: () => void | Promise<void>
308
- }
309
-
310
- /**
311
- * Marker that this module wants an isolated storage area at
312
- * `~/.openpen/plugins/<id>/data/`. Capacity / quota policy is defined
313
- * by the host runtime.
314
- */
315
- export interface StorageContribution {
316
- /** If true, the host pre-creates the data directory at module load. */
317
- preCreate?: boolean
318
- }
319
-
320
- /**
321
- * Map of action name to async handler. Routed from the renderer via
322
- * `window.openPenApi.moduleCall(moduleId, action, payload)`.
323
- */
324
- export type MainHandlerContribution = Record<
325
- string,
326
- (payload: unknown) => unknown | Promise<unknown>
327
- >
328
-
329
- export interface FileDropContribution {
330
- /** MIME types this handler accepts (e.g. `['image/png']`). */
331
- accept: string[]
332
- handle: (files: File[]) => void | Promise<void>
333
- }
@@ -1,174 +0,0 @@
1
- import { z } from 'zod'
2
- import type { Component } from 'vue'
3
- import type { LocaleMap } from '../locale'
4
-
5
- // ── Schema ────────────────────────────────────────────────────────────────
6
-
7
- export const GroupInsetSchema = z.object({
8
- enabled: z.boolean(),
9
- /** Optional CSS color override for the inset background; defaults to --openpen-color-control-group. */
10
- color: z.string().optional(),
11
- })
12
-
13
- export const LayoutGroupSchema = z.object({
14
- id: z.string().regex(/^[a-z][a-z0-9-]*$/, 'Group id must be kebab-case'),
15
- items: z.array(z.string()),
16
- separator: z.enum(['auto', 'always', 'never']).optional(),
17
- /**
18
- * Optional inset rendering for this group. When `enabled: true`, the group
19
- * MUST render with a visible bg + border + padding container (the design's
20
- * "grouped tools" look) AND its inner items MUST shrink so the group's
21
- * outer height matches an unwrapped 36px button — i.e. enabling inset
22
- * MUST NOT increase control-bar height.
23
- */
24
- inset: GroupInsetSchema.optional(),
25
- })
26
-
27
- export const ControlBarLayoutSchema = z.object({
28
- version: z.literal(1),
29
- groups: z
30
- .array(LayoutGroupSchema)
31
- .refine((groups) => groups.some((g) => g.id === 'default'), {
32
- message: "Layout must contain a 'default' group",
33
- })
34
- .refine(
35
- (groups) => {
36
- const ids = groups.map((g) => g.id)
37
- return new Set(ids).size === ids.length
38
- },
39
- { message: 'Group ids must be unique' },
40
- )
41
- .refine(
42
- (groups) => {
43
- const allItems = groups.flatMap((g) => g.items)
44
- return new Set(allItems).size === allItems.length
45
- },
46
- { message: 'Item ids must not appear in multiple groups' },
47
- ),
48
- })
49
-
50
- // ── TypeScript types ──────────────────────────────────────────────────────
51
-
52
- export interface GroupInset {
53
- enabled: boolean
54
- /** Optional CSS color override for the inset background; defaults to --openpen-color-control-group. */
55
- color?: string
56
- }
57
-
58
- export interface LayoutGroup {
59
- id: string
60
- items: string[]
61
- separator?: 'auto' | 'always' | 'never'
62
- /** When `enabled: true`, render the group with the visible "grouped tools" container. */
63
- inset?: GroupInset
64
- }
65
-
66
- export interface ControlBarLayout {
67
- version: 1
68
- groups: LayoutGroup[]
69
- }
70
-
71
- // ── Contribution type ─────────────────────────────────────────────────────
72
-
73
- export interface ControlBarContribution {
74
- /** Item id — MUST be globally unique across all modules. */
75
- id: string
76
- /** Vue component rendered for this item. */
77
- component: Component
78
- /**
79
- * Preferred group id on first install.
80
- * MUST NOT be 'default' (reserved).
81
- * When omitted, item falls into the 'default' group.
82
- */
83
- defaultGroup?: string
84
- /**
85
- * Hints used when host auto-creates the defaultGroup.
86
- * Ignored if the group already exists in the layout.
87
- */
88
- groupHint?: {
89
- separator?: 'auto' | 'always' | 'never'
90
- label?: string | LocaleMap
91
- }
92
- }
93
-
94
- // ── Default layout ────────────────────────────────────────────────────────
95
-
96
- /** Baseline layout used on first launch: a single empty `'default'` group. */
97
- export const DEFAULT_CONTROL_BAR_LAYOUT: ControlBarLayout = {
98
- version: 1,
99
- groups: [{ id: 'default', items: [], separator: 'auto' }],
100
- }
101
-
102
- // ── Reconcile helpers ─────────────────────────────────────────────────────
103
-
104
- /**
105
- * Apply L3a non-destructive repairs to a parsed layout:
106
- * - Ensure 'default' group exists
107
- * - Replace invalid separator values with 'auto'
108
- * Returns the (possibly mutated copy) and whether any repair was made.
109
- *
110
- * @internal Host-only utility; not part of the plugin author API.
111
- */
112
- export function repairLayoutL3a(layout: ControlBarLayout): {
113
- layout: ControlBarLayout
114
- repaired: boolean
115
- } {
116
- let repaired = false
117
- const groups = layout.groups.map((g) => {
118
- if (g.separator !== undefined && !['auto', 'always', 'never'].includes(g.separator)) {
119
- repaired = true
120
- return { ...g, separator: 'auto' as const }
121
- }
122
- return g
123
- })
124
- if (!groups.some((g) => g.id === 'default')) {
125
- groups.push({ id: 'default', items: [], separator: 'auto' })
126
- repaired = true
127
- }
128
- return { layout: { ...layout, groups }, repaired }
129
- }
130
-
131
- /**
132
- * Apply L3b potentially-destructive repairs:
133
- * - De-duplicate item ids across groups (keep last occurrence)
134
- * - Trim ghost ids when count ≥ 20 (noop — ghost trimming is a UX action)
135
- * Returns the repaired layout and whether any repair was made.
136
- *
137
- * @internal Host-only utility; not part of the plugin author API.
138
- */
139
- export function repairLayoutL3b(
140
- layout: ControlBarLayout,
141
- knownItemIds: Set<string>,
142
- ): { layout: ControlBarLayout; repaired: boolean } {
143
- let repaired = false
144
- const seen = new Set<string>()
145
- // Process groups in reverse so the last occurrence wins.
146
- const reversedGroups = [...layout.groups].reverse()
147
- const deduped = reversedGroups.map((g) => {
148
- const items = g.items.filter((id) => {
149
- if (seen.has(id)) {
150
- repaired = true
151
- return false
152
- }
153
- seen.add(id)
154
- return true
155
- })
156
- return { ...g, items }
157
- })
158
- const groups = deduped.reverse()
159
-
160
- // Count ghosts across layout
161
- const totalItems = groups.flatMap((g) => g.items)
162
- const ghostCount = totalItems.filter((id) => !knownItemIds.has(id)).length
163
- if (ghostCount >= 20) {
164
- repaired = true
165
- // Remove ghosts from all groups
166
- const cleaned = groups.map((g) => ({
167
- ...g,
168
- items: g.items.filter((id) => knownItemIds.has(id)),
169
- }))
170
- return { layout: { ...layout, groups: cleaned }, repaired }
171
- }
172
-
173
- return { layout: { ...layout, groups }, repaired }
174
- }