@openpen/module-api 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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/package.json +53 -0
  4. package/src/define-module.ts +55 -0
  5. package/src/host/color-utils.ts +18 -0
  6. package/src/host/event-bus.ts +37 -0
  7. package/src/host/host-commands.ts +9 -0
  8. package/src/host/index.ts +53 -0
  9. package/src/host/module-context-registry.ts +34 -0
  10. package/src/host/passthrough.ts +10 -0
  11. package/src/host/popup-anchor.ts +14 -0
  12. package/src/host/slot-entries.ts +9 -0
  13. package/src/host/stroke-store.ts +20 -0
  14. package/src/host/stroke-style.ts +15 -0
  15. package/src/index.ts +106 -0
  16. package/src/inject-keys.ts +143 -0
  17. package/src/locale.ts +52 -0
  18. package/src/slots.ts +135 -0
  19. package/src/types/contributions.ts +298 -0
  20. package/src/types/control-bar-layout.ts +174 -0
  21. package/src/types/module.ts +225 -0
  22. package/src/types/notification.ts +40 -0
  23. package/src/types/tool.ts +82 -0
  24. package/src/uikit/components/.gitkeep +0 -0
  25. package/src/uikit/components/AppBanner.vue +131 -0
  26. package/src/uikit/components/AppButton.vue +135 -0
  27. package/src/uikit/components/AppDialog.vue +393 -0
  28. package/src/uikit/components/AppPopover.vue +268 -0
  29. package/src/uikit/components/AppSegmented.vue +126 -0
  30. package/src/uikit/components/AppSelect.vue +235 -0
  31. package/src/uikit/components/AppSlider.vue +204 -0
  32. package/src/uikit/components/AppTabs.vue +163 -0
  33. package/src/uikit/components/AppToggle.vue +96 -0
  34. package/src/uikit/components/AppTooltip.vue +118 -0
  35. package/src/uikit/components/DialogHost.vue +260 -0
  36. package/src/uikit/composables/dialog-internal-keys.ts +17 -0
  37. package/src/uikit/composables/useDialog.ts +141 -0
  38. package/src/uikit/composables/useDialogPluginComponent.ts +27 -0
  39. package/src/uikit/index.ts +79 -0
  40. package/src/uikit/internal.ts +12 -0
  41. package/src/uikit/primitives.ts +150 -0
  42. package/src/uikit/tokens.css +205 -0
  43. package/src/use-module-context.ts +50 -0
  44. package/src/validation.ts +31 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Vue InjectionKey symbols that host code provides and module components inject.
3
+ *
4
+ * Module components MUST NOT import host composables directly (boundary rule).
5
+ * Instead, the host provides these contexts via Vue's provide/inject, and
6
+ * modules reference the keys via this file (part of the public SDK surface).
7
+ */
8
+ import type { InjectionKey, Ref } from 'vue'
9
+ import type { StrokeColor } from './types/tool'
10
+
11
+ // ── Stroke Style ─────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Provides read + write access to the global stroke style state.
15
+ * Provided by App.vue (root); available in all renderer windows.
16
+ *
17
+ * Module components: inject via `inject(STROKE_STYLE_CONTEXT_KEY)`.
18
+ */
19
+ export interface StrokeStyleContext {
20
+ lineWidth: Readonly<Ref<number>>
21
+ color: Readonly<Ref<StrokeColor>>
22
+ setLineWidth: (w: number) => void
23
+ setColor: (c: StrokeColor) => void
24
+ }
25
+
26
+ export const STROKE_STYLE_CONTEXT_KEY: InjectionKey<StrokeStyleContext> =
27
+ Symbol('openpen.strokeStyleContext')
28
+
29
+ // ── Snap Edge ────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Which side of the screen workArea the floating ball is anchored to.
33
+ *
34
+ * - 'left' → ball anchored on the left edge (vbar-left layout)
35
+ * - 'right' → ball anchored on the right edge (vbar-right layout)
36
+ * - 'top' → ball anchored on the top edge (horizontal bar, top)
37
+ * - 'bottom' → ball anchored on the bottom edge (horizontal bar, bottom)
38
+ * - null → not snapped (free-floating or horizontal default)
39
+ *
40
+ * Consumers that need a ready-to-use popup direction (the opposite of the
41
+ * snap edge) should inject POPOVER_PLACEMENT_HINT_KEY instead — it encodes
42
+ * the two-tier UX rule (snap edge → midline fallback) so individual components
43
+ * do not have to replicate the logic.
44
+ *
45
+ * Use IS_VERTICAL_KEY for orientation checks (vbar vs horizontal); this key
46
+ * is null in vbar-free mode where no edge is snapped.
47
+ */
48
+ export type SnapEdge = 'left' | 'right' | 'top' | 'bottom' | null
49
+
50
+ export const SNAP_EDGE_KEY: InjectionKey<Readonly<Ref<SnapEdge>>> =
51
+ Symbol('openpen.snapEdge')
52
+
53
+ // ── Vertical Layout ──────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * True for any vertical layout (vbar-left / vbar-right / vbar-free). Use
57
+ * this for orientation; SNAP_EDGE_KEY is edge-specific and null in vbar-free.
58
+ */
59
+ export const IS_VERTICAL_KEY: InjectionKey<Readonly<Ref<boolean>>> =
60
+ Symbol('openpen.isVertical')
61
+
62
+ // ── Modal Manager ─────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Programmatic open / close for modals registered via `ui.modals` slot.
66
+ * Provided by ModalStack.vue.
67
+ *
68
+ * Module control-bar buttons call `open(id)` to show their modal.
69
+ * Modal components call `isOpen(id)` to control their own visibility.
70
+ */
71
+ export interface ModalManager {
72
+ /** Open the modal with the given id. Closes any currently-open modal first. */
73
+ open: (id: string) => void
74
+ /** Close the modal with the given id. No-op if it is not open. */
75
+ close: (id: string) => void
76
+ /** Returns true if the modal with the given id is currently open. */
77
+ isOpen: (id: string) => boolean
78
+ }
79
+
80
+ export const MODAL_MANAGER_KEY: InjectionKey<ModalManager> =
81
+ Symbol('openpen.modalManager')
82
+
83
+ // ── Control Bar Layout Context ───────────────────────────────────────────────
84
+
85
+ /**
86
+ * Reference to the `.control-bar-wrapper` element. Provided by ControlBar.vue.
87
+ * Module components that open anchor-relative popups teleport into this element.
88
+ */
89
+ export const WRAPPER_EL_KEY: InjectionKey<Readonly<Ref<HTMLElement | null>>> =
90
+ Symbol('openpen.wrapperEl')
91
+
92
+ /**
93
+ * Reference to the `.control-bar` element. Provided by ControlBar.vue.
94
+ * Module components use this as the anchor for popup positioning.
95
+ */
96
+ export const ANCHOR_EL_KEY: InjectionKey<Readonly<Ref<HTMLElement | null>>> =
97
+ Symbol('openpen.anchorEl')
98
+
99
+ // ── Control Bar Animating ──────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Reactive flag that is `true` while the ControlBar is running a scaleY
103
+ * animation (expand 350ms / collapse 250ms / snap 250ms).
104
+ *
105
+ * Provided by ControlBar.vue as `readonly(animating)`.
106
+ * AppPopover (and any other UIKit wrapper) watches this and auto-closes
107
+ * when it becomes true, avoiding floating-ui position drift during animation.
108
+ */
109
+ export const CONTROL_BAR_ANIMATING_KEY: InjectionKey<Readonly<Ref<boolean>>> =
110
+ Symbol('openpen:control-bar-animating')
111
+
112
+ // ── Host Dialog Presence ─────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Reference-counted open-dialog count. Hosts inject this to render their
116
+ * own "locked" state when > 0 — a transparent overlay window can't use a
117
+ * dim/blur dialog backdrop without rendering as an isolated dark patch.
118
+ */
119
+ export const HOST_DIALOG_OPEN_COUNT_KEY: InjectionKey<Ref<number>> =
120
+ Symbol('openpen:hostDialogOpenCount')
121
+
122
+ // ── Popover Placement Hint ────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Deliberate UX rule for initial popover placement direction.
126
+ *
127
+ * Encodes which side of the trigger the popover should prefer to open on,
128
+ * based on two rules applied in priority order:
129
+ * 1. Snap edge: opening away from the snapped edge (e.g. snap-left → 'right').
130
+ * 2. Screen midline (when no snap edge): opening toward the side with more space
131
+ * on the relevant axis (horizontal bar → top/bottom; vertical bar → left/right).
132
+ *
133
+ * The floating-ui flip middleware handles secondary overflow; this hint is the
134
+ * proactive first choice so the popover starts on the correct side without
135
+ * requiring a layout-measurement round-trip.
136
+ *
137
+ * Provided by ControlBar.vue; injected by AppPopover.vue.
138
+ * Plugin authors do not need to consume this key directly.
139
+ */
140
+ export type PopoverPlacementHint = 'top' | 'bottom' | 'left' | 'right'
141
+
142
+ export const POPOVER_PLACEMENT_HINT_KEY: InjectionKey<Readonly<Ref<PopoverPlacementHint>>> =
143
+ Symbol('openpen:popoverPlacementHint')
package/src/locale.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Convert a scoped module id to a vue-i18n-safe namespace key.
3
+ *
4
+ * vue-i18n uses `.` as path separator, so a raw id like `@openpen/freehand`
5
+ * would be misinterpreted as a three-level path `['@openpen', 'freehand']`.
6
+ * Stripping `@` and replacing `/` with `.` produces `openpen.freehand`,
7
+ * which resolves correctly as a two-level namespace.
8
+ *
9
+ * @example
10
+ * sanitizeIdForI18n('@openpen/freehand') // 'openpen.freehand'
11
+ * sanitizeIdForI18n('@alice/todo') // 'alice.todo'
12
+ */
13
+ export function sanitizeIdForI18n(moduleId: string): string {
14
+ return moduleId.replace(/^@/, '').replace(/\//g, '.')
15
+ }
16
+
17
+ /**
18
+ * Maps a BCP-47 locale tag (e.g. `'en'`, `'zh-TW'`) to a localised string.
19
+ *
20
+ * Modules use this to declare i18n display names without coupling
21
+ * themselves to the host app's i18n framework. The OpenPen registry
22
+ * resolves the value at render time based on the active locale.
23
+ */
24
+ export type LocaleMap = Record<string, string>
25
+
26
+ /**
27
+ * Resolve a `string | LocaleMap` to a single string for a given locale.
28
+ *
29
+ * Resolution order (first match wins):
30
+ * 1. Plain string → returned as-is
31
+ * 2. Exact tag match (e.g. `'zh-TW'`)
32
+ * 3. Language prefix match (e.g. `'zh'` for `'zh-Hans-CN'`)
33
+ * 4. English fallback (`'en'`)
34
+ * 5. First declared value in the map
35
+ * 6. Empty string (only when the map is `{}`)
36
+ */
37
+ export function resolveLabel(
38
+ input: string | LocaleMap,
39
+ locale: string
40
+ ): string {
41
+ if (typeof input === 'string') return input
42
+
43
+ if (input[locale] !== undefined) return input[locale]
44
+
45
+ const lang = locale.split('-')[0]
46
+ if (lang !== locale && input[lang] !== undefined) return input[lang]
47
+
48
+ if (input.en !== undefined) return input.en
49
+
50
+ const first = Object.values(input)[0]
51
+ return first ?? ''
52
+ }
package/src/slots.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * OpenPen Contribution Slot catalogue.
3
+ *
4
+ * Slots are the only places where modules can affect the host app.
5
+ * Adding a slot is an architectural change — it usually requires a
6
+ * new adapter on the OpenPen side and a minor version bump of this
7
+ * package. Removing a slot is a breaking change.
8
+ *
9
+ * `status: 'v1'` slots are wired through to runtime adapters.
10
+ * `status: 'reserved'` slots accept registrations and pass validation;
11
+ * no runtime adapter is active yet so contributions are stored but
12
+ * inert. Forward-compatible: modules can ship against reserved slots
13
+ * without any plugin-side changes once an adapter lands.
14
+ */
15
+
16
+ export type SlotCategory = 'canvas' | 'ui' | 'system'
17
+ export type SlotStatus = 'v1' | 'reserved'
18
+
19
+ export interface SlotDefinition {
20
+ /** Dotted slot id, e.g. `'canvas.tools'`. */
21
+ id: string
22
+ /** Implementation status — see file header. */
23
+ status: SlotStatus
24
+ /** High-level grouping for documentation and UI. */
25
+ category: SlotCategory
26
+ /** One-sentence purpose, shown in `docs/reference/slots.md`. */
27
+ description: string
28
+ }
29
+
30
+ /**
31
+ * v1 active slots (16). Wired through to runtime adapters.
32
+ */
33
+ export const V1_ACTIVE_SLOTS: readonly SlotDefinition[] = [
34
+ // Canvas
35
+ { id: 'canvas.tools', status: 'v1', category: 'canvas', description: 'Drawing tools driven by pointer events.' },
36
+ { id: 'canvas.shapes', status: 'v1', category: 'canvas', description: 'Shape primitives consumed by the shape tool.' },
37
+ { id: 'canvas.stroke.style', status: 'v1', category: 'canvas', description: 'Writers into the shared stroke style store.' },
38
+ { id: 'canvas.layers.background', status: 'v1', category: 'canvas', description: 'Render below strokes (grids, watermarks).' },
39
+ { id: 'canvas.layers.overlay', status: 'v1', category: 'canvas', description: 'Render above strokes (rulers, guides).' },
40
+ { id: 'canvas.html.overlay', status: 'v1', category: 'canvas', description: 'Mount HTML elements above the canvas (text, stickers).' },
41
+ // UI
42
+ { id: 'ui.control-bar', status: 'v1', category: 'ui', description: 'Control bar item slot (groups and ordering managed by layout config).' },
43
+ { id: 'ui.settings.tabs', status: 'v1', category: 'ui', description: 'Tabs in the settings window.' },
44
+ { id: 'ui.settings.panels', status: 'v1', category: 'ui', description: 'Sections inside the Features settings tab, grouped by module.' },
45
+ { id: 'ui.cursors', status: 'v1', category: 'ui', description: 'Drawing-mode cursor styles.' },
46
+ { id: 'ui.status', status: 'v1', category: 'ui', description: 'Ephemeral status badges on the control bar.' },
47
+ { id: 'ui.modals', status: 'v1', category: 'ui', description: 'Registered modals managed by the global modal stack.' },
48
+ // System
49
+ { id: 'system.shortcuts', status: 'v1', category: 'system', description: 'Global and drawing-mode keyboard shortcuts.' },
50
+ { id: 'system.locales', status: 'v1', category: 'system', description: 'i18n dictionary contributions.' },
51
+ { id: 'system.main.handlers', status: 'v1', category: 'system', description: 'Node-side IPC handlers for main process capabilities.' },
52
+ { id: 'system.events', status: 'v1', category: 'system', description: 'Subscribers and emitters on the domain event bus.' },
53
+ { id: 'system.lifecycle', status: 'v1', category: 'system', description: 'App lifecycle hooks (onReady / onSuspend / onQuit).' },
54
+ ] as const
55
+
56
+ /**
57
+ * v1 reserved slots. Registrations pass validation but no runtime
58
+ * adapter is active yet.
59
+ */
60
+ export const V1_RESERVED_SLOTS: readonly SlotDefinition[] = [
61
+ { id: 'canvas.history.commands', status: 'reserved', category: 'canvas', description: 'Undo/redo history commands. Adapter not yet active; built-in command types are used internally.' },
62
+ { id: 'canvas.stroke.transformers', status: 'reserved', category: 'canvas', description: 'Post-process strokes after creation (smooth, simplify).' },
63
+ { id: 'ui.tray.menu', status: 'reserved', category: 'ui', description: 'System tray menu items. Adapter not yet active.' },
64
+ { id: 'ui.context.menu', status: 'reserved', category: 'ui', description: 'Right-click context menu items on canvas / control bar.' },
65
+ { id: 'ui.theme.tokens', status: 'reserved', category: 'ui', description: 'CSS custom property contributions (colour swatches, spacing tokens).' },
66
+ { id: 'system.window.behaviors', status: 'reserved', category: 'system', description: 'Window behavior customizations. Adapter not yet active.' },
67
+ { id: 'system.storage', status: 'reserved', category: 'system', description: 'Per-plugin isolated storage at ~/.openpen/plugins/<id>/data/. Adapter not yet active; first real consumer will drive the storage backend design.' },
68
+ { id: 'system.file.drop', status: 'reserved', category: 'system', description: 'Handlers for files dropped onto the canvas.' },
69
+ ] as const
70
+
71
+ /**
72
+ * All slots (active + reserved). Source of truth for validation.
73
+ */
74
+ export const ALL_SLOTS: readonly SlotDefinition[] = [
75
+ ...V1_ACTIVE_SLOTS,
76
+ ...V1_RESERVED_SLOTS,
77
+ ]
78
+
79
+ const SLOT_BY_ID = new Map<string, SlotDefinition>(
80
+ ALL_SLOTS.map((s) => [s.id, s])
81
+ )
82
+
83
+ /** Returns `true` when `id` matches a slot in {@link ALL_SLOTS} (active or reserved). */
84
+ export function isKnownSlot(id: string): boolean {
85
+ return SLOT_BY_ID.has(id)
86
+ }
87
+
88
+ /**
89
+ * Returns the {@link SlotDefinition} for a known slot id, or `undefined` if
90
+ * the id is not registered in {@link ALL_SLOTS}.
91
+ */
92
+ export function getSlot(id: string): SlotDefinition | undefined {
93
+ return SLOT_BY_ID.get(id)
94
+ }
95
+
96
+ /**
97
+ * Mapping from `contributes` field name (camelCase) to slot id (dotted).
98
+ *
99
+ * Modules express contributions via the ergonomic camelCase keys on
100
+ * `contributes` (e.g. `historyCommands`); the validator maps them
101
+ * back to slot ids (`canvas.history.commands`) before checking against
102
+ * `ALL_SLOTS`.
103
+ *
104
+ * Adding a new field here is part of adding a new slot.
105
+ */
106
+ export const CONTRIBUTION_KEY_TO_SLOT_ID: Record<string, string> = {
107
+ // Canvas
108
+ tools: 'canvas.tools',
109
+ shapes: 'canvas.shapes',
110
+ strokeStyle: 'canvas.stroke.style',
111
+ historyCommands: 'canvas.history.commands',
112
+ backgroundLayers: 'canvas.layers.background',
113
+ overlayLayers: 'canvas.layers.overlay',
114
+ htmlOverlays: 'canvas.html.overlay',
115
+ strokeTransformers: 'canvas.stroke.transformers',
116
+ // UI
117
+ controlBar: 'ui.control-bar',
118
+ settingsTabs: 'ui.settings.tabs',
119
+ settingsPanels: 'ui.settings.panels',
120
+ cursors: 'ui.cursors',
121
+ status: 'ui.status',
122
+ modals: 'ui.modals',
123
+ trayMenu: 'ui.tray.menu',
124
+ contextMenu: 'ui.context.menu',
125
+ themeTokens: 'ui.theme.tokens',
126
+ // System
127
+ shortcuts: 'system.shortcuts',
128
+ windowBehaviors: 'system.window.behaviors',
129
+ locales: 'system.locales',
130
+ events: 'system.events',
131
+ lifecycle: 'system.lifecycle',
132
+ storage: 'system.storage',
133
+ mainHandlers: 'system.main.handlers',
134
+ fileDrop: 'system.file.drop',
135
+ }
@@ -0,0 +1,298 @@
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
+ export interface CursorContribution {
165
+ id: string
166
+ /** CSS cursor value or a data URL. */
167
+ cursor: string
168
+ }
169
+
170
+ export interface StatusContribution {
171
+ id: string
172
+ component: Component
173
+ }
174
+
175
+ export interface ModalContribution {
176
+ id: string
177
+ component: Component
178
+ }
179
+
180
+ export interface TrayMenuContribution {
181
+ id: string
182
+ label: string | LocaleMap
183
+ click: () => void
184
+ }
185
+
186
+ export interface ContextMenuContribution {
187
+ id: string
188
+ label: string | LocaleMap
189
+ /** Where the menu may appear; runtime filters by context at open time. */
190
+ context: 'canvas' | 'toolbar' | 'tray'
191
+ click: () => void
192
+ }
193
+
194
+ /**
195
+ * CSS custom property contributions for the `ui.theme.tokens` slot.
196
+ *
197
+ * Keys are CSS custom property names (e.g. `'--my-plugin-accent'`).
198
+ * Values are CSS color or length values (e.g. `'#ff6600'`, `'4px'`).
199
+ * The host merges all contributions into `:root` at startup.
200
+ */
201
+ export type ThemeTokenContribution = Record<string, string>
202
+
203
+ export interface ShortcutContribution {
204
+ id: string
205
+ /** Accelerator string; tinykeys-style or Electron globalShortcut format. */
206
+ keys: string
207
+ /** `'global'` registers OS-wide; `'drawing'` only when overlay is active. */
208
+ scope: 'global' | 'drawing'
209
+ handler: () => void
210
+ /** Human-readable name. Omit to hide this shortcut from the user-visible shortcuts list. */
211
+ label?: string | LocaleMap
212
+ /** Optional secondary description shown below the label in the shortcuts settings panel. */
213
+ sublabel?: string | LocaleMap
214
+ /** When true, the user can rebind this key. The chosen key is persisted under `config.json → customShortcuts`. */
215
+ userCustomizable?: boolean
216
+ }
217
+
218
+ export interface WindowBehaviorContribution {
219
+ id: string
220
+ /** Reserved — concrete behaviour API not yet defined. */
221
+ [key: string]: unknown
222
+ }
223
+
224
+ /** Leaf or branch in a locale dictionary. Leaves must be strings. */
225
+ type LocaleDictValue = string | LocaleDict
226
+
227
+ /**
228
+ * Recursive locale dictionary.
229
+ * Leaves are translated strings; branches are nested sub-dictionaries.
230
+ * Use the `interface` form (not `type`) to allow self-reference without
231
+ * triggering TypeScript's circular-type restrictions.
232
+ */
233
+ interface LocaleDict {
234
+ [key: string]: LocaleDictValue
235
+ }
236
+
237
+ /**
238
+ * A locale dictionary keyed by BCP-47 tag (e.g. `'en'`, `'zh-Hant'`).
239
+ *
240
+ * Values are per-locale message dictionaries. Supports both flat and nested
241
+ * layouts — vue-i18n treats `.` in ctx.t(key) as a nested path separator,
242
+ * so keys like `'notif.ready'` must use a nested object dict
243
+ * `{ notif: { ready: '...' } }`, not a flat key `{ 'notif.ready': '...' }`.
244
+ *
245
+ * Non-string leaves are rejected at compile time; use ctx.t() to resolve
246
+ * keys to strings before passing them to runtime APIs.
247
+ */
248
+ export type LocaleContribution = Record<string, LocaleDict>
249
+
250
+ /**
251
+ * Subscription to a named domain event on the `system.events` slot.
252
+ *
253
+ * `event` must match the event name emitted by another module (or the host)
254
+ * via `system.events`. The host routes each emitted event to all registered
255
+ * handlers whose `event` string matches.
256
+ */
257
+ export interface EventSubscriptionContribution {
258
+ event: string
259
+ handler: (payload: unknown) => void
260
+ }
261
+
262
+ /**
263
+ * App lifecycle hooks for the `system.lifecycle` slot.
264
+ *
265
+ * Call order: `setup` (from `OpenPenModule.setup`) → `onReady` (host fully
266
+ * booted) → `onSuspend` (overlay hidden) → `onQuit` (app shutting down).
267
+ * All hooks may return a Promise; the host awaits each before advancing.
268
+ */
269
+ export interface LifecycleContribution {
270
+ onReady?: () => void | Promise<void>
271
+ onSuspend?: () => void | Promise<void>
272
+ onQuit?: () => void | Promise<void>
273
+ }
274
+
275
+ /**
276
+ * Marker that this module wants an isolated storage area at
277
+ * `~/.openpen/plugins/<id>/data/`. Capacity / quota policy is defined
278
+ * by the host runtime.
279
+ */
280
+ export interface StorageContribution {
281
+ /** If true, the host pre-creates the data directory at module load. */
282
+ preCreate?: boolean
283
+ }
284
+
285
+ /**
286
+ * Map of action name to async handler. Routed from the renderer via
287
+ * `window.openPenApi.moduleCall(moduleId, action, payload)`.
288
+ */
289
+ export type MainHandlerContribution = Record<
290
+ string,
291
+ (payload: unknown) => unknown | Promise<unknown>
292
+ >
293
+
294
+ export interface FileDropContribution {
295
+ /** MIME types this handler accepts (e.g. `['image/png']`). */
296
+ accept: string[]
297
+ handle: (files: File[]) => void | Promise<void>
298
+ }