@openforge-app/plugin-sdk 0.2.10 → 0.3.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.
@@ -1,7 +1,8 @@
1
1
  import type { BrowserSurfaceVisualFeedback } from '../browserSurfaces';
2
+ import type { CompletedTaskQuery } from '../domain';
2
3
  import type { TestingOpenForgeRegistryFake } from './registryFake';
3
4
  import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, AgentSessionWorkspace, ListAgentSessionsRequest, ListTaskSessionsRequest, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginReviewRowActionRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
4
- import type { AgentSession, Task } from '../domain';
5
+ import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
5
6
  export type TestingRuntimeScope = 'global' | 'project' | 'task';
6
7
  export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'reviewUI' | 'settings' | 'backend' | 'background';
7
8
  export type TestingMaybePromise<T> = T | Promise<T>;
@@ -16,6 +17,10 @@ export interface TestingExternalTextFile extends ExternalReadFileRequest {
16
17
  export type TestingExternalTextFileChunksCall = Omit<ExternalReadTextFileChunksRequest, 'signal' | 'chunkSizeBytes'> & {
17
18
  chunkSizeBytes: number;
18
19
  };
20
+ export interface TestingTaskLabelAssignment {
21
+ taskId: string;
22
+ labels: TaskLabel[];
23
+ }
19
24
  export interface TestingOpenForgeApiOptions {
20
25
  pluginId?: string;
21
26
  projectId?: string | null;
@@ -26,12 +31,16 @@ export interface TestingOpenForgeApiOptions {
26
31
  storage?: PluginStorage;
27
32
  /** Initial files exposed through `fs.userData`. Defaults to none. */
28
33
  userDataTextFiles?: UserDataFileWriteRequest[];
34
+ /** File contents returned by `fs.readFile`, keyed by project-relative path. */
35
+ projectFileContents?: Readonly<Record<string, FileContent>>;
29
36
  /**
30
37
  * Tasks returned by `tasks.list`. The mock filters them by the requested
31
38
  * `projectId` (when given) and drops `done` tasks unless `includeDone: true`,
32
39
  * mirroring the host capability. Defaults to an empty list.
33
40
  */
34
41
  tasks?: Task[];
42
+ /** Task Label assignments used by canonical Task projections. */
43
+ taskLabelAssignments?: TestingTaskLabelAssignment[];
35
44
  /** Agent Sessions returned by `tasks.listSessions`. Defaults to an empty list. */
36
45
  agentSessions?: AgentSession[];
37
46
  /** Compact workspace context keyed by Task ID for `agentSessions.list`. Defaults to none. */
@@ -76,6 +85,16 @@ export interface TestingOpenForgeApiCalls {
76
85
  projectId: string | null;
77
86
  includeDone: boolean;
78
87
  }>;
88
+ taskActiveRequests: Array<{
89
+ projectId: string;
90
+ }>;
91
+ taskCompletedRequests: Array<{
92
+ projectId: string;
93
+ } & CompletedTaskQuery>;
94
+ taskDetailRequests: Array<{
95
+ projectId: string;
96
+ taskId: string;
97
+ }>;
79
98
  agentSessionListRequests: ListAgentSessionsRequest[];
80
99
  taskSessionListRequests: ListTaskSessionsRequest[];
81
100
  taskStatusUpdates: Array<{
@@ -1,5 +1,5 @@
1
1
  import type { TaskBrowserSurfaceState } from '../browserSurfaces';
2
- import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, PluginStorage } from '../types';
2
+ import type { BackendPlugin, BackendPluginContext, FrontendPlugin, FrontendPluginContext, OpenForgePackageMetadata, TaskChangeEvent, PluginStorage } from '../types';
3
3
  import type { MockBackendOpenForgeAPI, MockFrontendOpenForgeAPI, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingOpenForgeRegistrySnapshot } from './contracts';
4
4
  import { TestingSubscriptionSink } from './support.js';
5
5
  export declare class TestingOpenForgeRegistryFake {
@@ -28,6 +28,7 @@ export declare class TestingOpenForgeRegistryFake {
28
28
  createBackendContext(): BackendPluginContext;
29
29
  activateFrontend(plugin: FrontendPlugin): Promise<void>;
30
30
  activateBackend(plugin: BackendPlugin): Promise<void>;
31
+ emitTaskChange(event: TaskChangeEvent): void;
31
32
  setBrowserSurfaceState(taskId: string, id: string, patch: Partial<TaskBrowserSurfaceState>): void;
32
33
  disposeAll(): Promise<void>;
33
34
  getSnapshot(): TestingOpenForgeRegistrySnapshot;
@@ -82,6 +82,9 @@ export class TestingOpenForgeRegistryFake {
82
82
  await plugin.activate(this.backendApi, this.createBackendContext());
83
83
  await this.backendServices.startNewBackgroundServices(existingServices);
84
84
  }
85
+ emitTaskChange(event) {
86
+ this.commonApi.emitTaskChange(event);
87
+ }
85
88
  setBrowserSurfaceState(taskId, id, patch) {
86
89
  this.frontendContributions.setBrowserSurfaceState(taskId, id, patch);
87
90
  }
@@ -1,5 +1,5 @@
1
1
  import type { AgentSessionWorkspace, AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
2
- import type { AgentSession, Task } from '../domain';
2
+ import type { AgentSession, FileContent, Task, TaskLabel } from '../domain';
3
3
  import type { TestingCommandContribution, TestingMaybePromise, TestingExternalTextFile, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingRuntimeKind } from './contracts';
4
4
  export declare function createDisposable(dispose: () => TestingMaybePromise<void>): Disposable;
5
5
  export declare class TestingSubscriptionSink implements SubscriptionSink {
@@ -31,10 +31,12 @@ export declare class TestingRegistryServices {
31
31
  readonly storage: PluginStorage;
32
32
  readonly config: Map<string, JsonValue>;
33
33
  readonly seededTasks: Task[];
34
+ readonly seededTaskLabelAssignments: Map<string, TaskLabel[]>;
34
35
  readonly seededAgentSessions: AgentSession[];
35
36
  readonly agentSessionWorkspaces: Readonly<Record<string, AgentSessionWorkspace>>;
36
37
  readonly externalTextFiles: TestingExternalTextFile[];
37
38
  readonly userDataTextFiles: Map<string, string>;
39
+ readonly projectFileContents: Readonly<Record<string, FileContent>>;
38
40
  readonly claims: TestingContributionClaims;
39
41
  constructor(options?: TestingOpenForgeApiOptions);
40
42
  localQualifiedId(kind: TestingRuntimeKind, id: string): string;
@@ -44,6 +44,9 @@ export function createTestingCalls() {
44
44
  taskImplementationStarts: [],
45
45
  taskFollowUps: [],
46
46
  taskListRequests: [],
47
+ taskActiveRequests: [],
48
+ taskCompletedRequests: [],
49
+ taskDetailRequests: [],
47
50
  agentSessionListRequests: [],
48
51
  taskSessionListRequests: [],
49
52
  taskStatusUpdates: [],
@@ -205,10 +208,12 @@ export class TestingRegistryServices {
205
208
  storage;
206
209
  config = new Map();
207
210
  seededTasks;
211
+ seededTaskLabelAssignments;
208
212
  seededAgentSessions;
209
213
  agentSessionWorkspaces;
210
214
  externalTextFiles;
211
215
  userDataTextFiles = new Map();
216
+ projectFileContents;
212
217
  claims = new TestingContributionClaims();
213
218
  constructor(options = {}) {
214
219
  this.pluginId = options.pluginId ?? 'test-plugin';
@@ -224,9 +229,11 @@ export class TestingRegistryServices {
224
229
  this.calls = createTestingCalls();
225
230
  this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
226
231
  this.seededTasks = options.tasks ?? [];
232
+ this.seededTaskLabelAssignments = new Map((options.taskLabelAssignments ?? []).map(assignment => [assignment.taskId, assignment.labels]));
227
233
  this.seededAgentSessions = options.agentSessions ?? [];
228
234
  this.agentSessionWorkspaces = options.agentSessionWorkspaces ?? {};
229
235
  this.externalTextFiles = options.externalTextFiles ?? [];
236
+ this.projectFileContents = options.projectFileContents ?? {};
230
237
  for (const file of options.userDataTextFiles ?? []) {
231
238
  this.userDataTextFiles.set(file.path, file.content);
232
239
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { BrowserSurfacesAPI } from './browserSurfaces';
3
- import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
3
+ import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ActiveTasks, CompletedTaskPage, CompletedTaskQuery, TaskRead, TaskDetail, ReviewPullRequest, Task, TaskWorkspaceInfo, WorktreeSource, WritableBoardStatus } from './domain';
4
4
  export type SupportedOpenForgeApiVersion = 1;
5
5
  export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
6
6
  export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
@@ -202,6 +202,7 @@ export interface PluginTaskPaneProps extends Record<string, unknown> {
202
202
  api: FrontendOpenForgeAPI;
203
203
  context: OpenForgeContextSnapshot;
204
204
  taskId: string;
205
+ task: TaskDetail;
205
206
  projectId: string | null;
206
207
  }
207
208
  export interface PluginTaskUISectionProps extends PluginTaskPaneProps {
@@ -501,6 +502,14 @@ export interface ComposeTaskRequest {
501
502
  initialPrompt: string;
502
503
  sourceTicketUrl?: string | null;
503
504
  title?: string | null;
505
+ /** Seeds the dialog's worktree source instead of the project default. */
506
+ worktreeSource?: WorktreeSource | null;
507
+ /**
508
+ * When `worktreeSource` is `existingBranch`, seeds the branch selector.
509
+ * Pull-request head refs may be short names; the host maps them onto the
510
+ * stored selector value (`origin/<name>` when origin has the branch).
511
+ */
512
+ worktreeBranch?: string | null;
504
513
  }
505
514
  export interface ComposeTaskResult {
506
515
  task: Task;
@@ -601,24 +610,38 @@ export interface AgentSessionSummaryPage {
601
610
  export interface AgentSessionsAPI {
602
611
  list(request: ListAgentSessionsRequest): Promise<AgentSessionSummaryPage>;
603
612
  }
604
- export interface TasksAPI {
613
+ export type TaskChangeReason = 'created' | 'updated' | 'completed' | 'attention' | 'execution';
614
+ /**
615
+ * A coalescible signal that one or more bounded Task reads may now be stale.
616
+ * It never contains a Task snapshot.
617
+ */
618
+ export interface TaskChangeEvent {
619
+ projectId: string;
620
+ taskId: string | null;
621
+ reason: TaskChangeReason;
622
+ }
623
+ export interface TaskOperationsAPI {
605
624
  /**
606
- * Lists tasks, optionally scoped to a project. By default done tasks are
607
- * excluded (matching the app board's active-only view); pass
608
- * `includeDone: true` to include tasks in the terminal `done` state. The
609
- * unscoped listing (no `projectId`) always returns all states, so
610
- * `includeDone` only affects the project-scoped path.
625
+ * Lists legacy Task rows. Project-scoped reads exclude Completed Tasks unless
626
+ * `includeDone` is true; unscoped reads preserve the complete legacy array.
627
+ * @deprecated Use `active`, `completed`, or `detail`. Removed in version 2.
611
628
  */
612
629
  list(request?: {
613
630
  projectId?: string | null;
614
631
  includeDone?: boolean;
615
632
  }): Promise<Task[]>;
633
+ /** @deprecated Use `detail`. Removed in version 2. */
616
634
  get(taskId: string): Promise<Task | null>;
635
+ active(projectId: string): Promise<ActiveTasks>;
636
+ completed(projectId: string, query?: CompletedTaskQuery): Promise<CompletedTaskPage>;
637
+ detail(projectId: string, taskId: string): Promise<TaskRead | null>;
617
638
  create(request: CreateTaskRequest): Promise<Task>;
618
639
  /**
619
640
  * Opens the host's create-task dialog pre-filled, letting the user edit the
620
641
  * prompt — including anything contributed at that injection point —
621
- * before the task exists.
642
+ * before the task exists. Optional `worktreeSource` / `worktreeBranch` seed
643
+ * the environment controls the same way `title` and `sourceTicketUrl` seed
644
+ * their fields.
622
645
  * Resolves null if they dismiss it.
623
646
  */
624
647
  compose(request: ComposeTaskRequest): Promise<ComposeTaskResult | null>;
@@ -632,6 +655,13 @@ export interface TasksAPI {
632
655
  /** Returns matching Agent Sessions newest first. */
633
656
  listSessions(request: ListTaskSessionsRequest): Promise<AgentSession[]>;
634
657
  }
658
+ export interface TasksAPI extends TaskOperationsAPI {
659
+ /**
660
+ * Subscribes to coalescible Task invalidations for one Project.
661
+ * Repeat the relevant bounded read instead of treating events as snapshots.
662
+ */
663
+ onDidChange(projectId: string, handler: (event: TaskChangeEvent) => void): Disposable;
664
+ }
635
665
  export interface ProjectsAPI {
636
666
  list(): Promise<Project[]>;
637
667
  get(projectId: string): Promise<Project | null>;
@@ -663,7 +693,7 @@ export interface OpenForgeCommonAPI {
663
693
  getSnapshot(): OpenForgeContextSnapshot;
664
694
  };
665
695
  agentSessions: AgentSessionsAPI;
666
- tasks: TasksAPI;
696
+ tasks: TaskOperationsAPI;
667
697
  projects: ProjectsAPI;
668
698
  fs: FileSystemAPI;
669
699
  shell: ShellAPI;
@@ -674,6 +704,7 @@ export interface OpenForgeCommonAPI {
674
704
  projectConfig: KeyValueConfigAPI;
675
705
  }
676
706
  export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
707
+ tasks: TasksAPI;
677
708
  browserSurfaces: BrowserSurfacesAPI;
678
709
  navigation: NavigationAPI;
679
710
  views: FrontendViewRegistry;
@@ -1,5 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { onDestroy } from 'svelte'
3
+ import { getRenderedMermaidDiagram, getRenderedMermaidSvg, observeMermaidTheme, renderMermaidDiagrams, type RenderedMermaidDiagram } from '../mermaid'
4
+ import MermaidDiagramPreview from './MermaidDiagramPreview.svelte'
3
5
  import {
4
6
  getMarkdownRepositoryLinkSuffix,
5
7
  MARKDOWN_REMOTE_MEDIA_ATTRIBUTE,
@@ -37,7 +39,11 @@
37
39
  }: Props = $props()
38
40
 
39
41
  let root = $state<HTMLDivElement | null>(null)
42
+ let activeMermaidDiagram = $state<RenderedMermaidDiagram | null>(null)
40
43
  let imageResolutionId = 0
44
+ let mermaidRenderId = 0
45
+ let mermaidThemeRevision = $state(0)
46
+ let stopObservingMermaidTheme: (() => void) | undefined
41
47
  let html = $derived(renderMarkdownHtml(content, {
42
48
  imageBaseUrl: resolveRepositoryImage ? null : imageBaseUrl,
43
49
  markdownFilePath,
@@ -137,6 +143,31 @@
137
143
  }))
138
144
  }
139
145
 
146
+ function synchronizeActiveMermaidDiagram() {
147
+ if (!activeMermaidDiagram) return
148
+
149
+ const svg = getRenderedMermaidSvg(activeMermaidDiagram.wrapper)
150
+ activeMermaidDiagram = svg ? { ...activeMermaidDiagram, svg } : null
151
+ }
152
+
153
+ $effect(() => {
154
+ if (!root || stopObservingMermaidTheme) return
155
+ stopObservingMermaidTheme = observeMermaidTheme(root.ownerDocument, () => {
156
+ mermaidThemeRevision++
157
+ })
158
+ })
159
+
160
+ $effect(() => {
161
+ const runId = ++mermaidRenderId
162
+ void html
163
+ void mermaidThemeRevision
164
+ if (!root) return
165
+
166
+ void renderMermaidDiagrams(root, () => runId === mermaidRenderId).then(() => {
167
+ if (runId === mermaidRenderId) synchronizeActiveMermaidDiagram()
168
+ })
169
+ })
170
+
140
171
  $effect(() => {
141
172
  const runId = ++imageResolutionId
142
173
  void html
@@ -149,6 +180,9 @@
149
180
 
150
181
  onDestroy(() => {
151
182
  imageResolutionId++
183
+ mermaidRenderId++
184
+ activeMermaidDiagram = null
185
+ stopObservingMermaidTheme?.()
152
186
  })
153
187
 
154
188
  function openMarkdownLink(href: string, absoluteHref: string) {
@@ -190,6 +224,14 @@
190
224
  function handleClick(event: MouseEvent) {
191
225
  if (!(event.target instanceof Element)) return
192
226
 
227
+ const diagram = getRenderedMermaidDiagram(event.target)
228
+ if (diagram) {
229
+ event.preventDefault()
230
+ diagram.trigger.focus()
231
+ activeMermaidDiagram = diagram
232
+ return
233
+ }
234
+
193
235
  const image = findEventImage(event.target)
194
236
  if (image && onOpenImage && image.getAttribute('src')) {
195
237
  event.preventDefault()
@@ -221,3 +263,7 @@
221
263
  <div bind:this={root} role="presentation" class="markdown-body" onclick={handleClick} onkeydown={handleKeydown}>
222
264
  {@html html}
223
265
  </div>
266
+
267
+ {#if activeMermaidDiagram}
268
+ <MermaidDiagramPreview svg={activeMermaidDiagram.svg} onClose={() => { activeMermaidDiagram = null }} />
269
+ {/if}
@@ -0,0 +1,216 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte'
3
+ import {
4
+ FIT_MERMAID_ZOOM,
5
+ calculateMermaidFitScale,
6
+ canZoomMermaidIn,
7
+ canZoomMermaidOut,
8
+ formatMermaidZoomLabel,
9
+ resetMermaidZoom,
10
+ resolveMermaidZoomScale,
11
+ zoomMermaidIn,
12
+ zoomMermaidOut,
13
+ type MermaidSize,
14
+ type MermaidZoomState,
15
+ } from '../mermaidZoom'
16
+ import Modal from './Modal.svelte'
17
+
18
+ interface Props {
19
+ svg: string
20
+ onClose: () => void
21
+ }
22
+
23
+ let { svg, onClose }: Props = $props()
24
+ let viewport = $state<HTMLDivElement | null>(null)
25
+ let svgHost = $state<HTMLDivElement | null>(null)
26
+ let closeButton = $state<HTMLButtonElement | null>(null)
27
+ let viewportSize = $state<MermaidSize>({ width: 0, height: 0 })
28
+ let zoom = $state<MermaidZoomState>(FIT_MERMAID_ZOOM)
29
+ let resizeObserver: ResizeObserver | undefined
30
+
31
+ function readSvgSize(markup: string): MermaidSize | null {
32
+ const template = document.createElement('template')
33
+ template.innerHTML = markup
34
+ const element = template.content.querySelector('svg')
35
+ if (!element) return null
36
+
37
+ const viewBox = element.getAttribute('viewBox')
38
+ ?.trim()
39
+ .split(/[\s,]+/)
40
+ .map(Number)
41
+ if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
42
+ return { width: viewBox[2], height: viewBox[3] }
43
+ }
44
+
45
+ const width = Number.parseFloat(element.getAttribute('width') ?? '')
46
+ const height = Number.parseFloat(element.getAttribute('height') ?? '')
47
+ return Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
48
+ ? { width, height }
49
+ : null
50
+ }
51
+
52
+ let diagramSize = $derived(readSvgSize(svg))
53
+ let fitScale = $derived(diagramSize
54
+ ? calculateMermaidFitScale(diagramSize, viewportSize)
55
+ : null)
56
+ let renderedScale = $derived(resolveMermaidZoomScale(zoom, fitScale))
57
+ let zoomLabel = $derived(formatMermaidZoomLabel(zoom, fitScale))
58
+ let renderedWidth = $derived(diagramSize ? diagramSize.width * renderedScale : null)
59
+ let renderedHeight = $derived(diagramSize ? diagramSize.height * renderedScale : null)
60
+
61
+ function updateViewportSize(width: number, height: number) {
62
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
63
+ viewportSize = { width, height }
64
+ }
65
+
66
+ $effect(() => {
67
+ if (!viewport || resizeObserver) return
68
+
69
+ const bounds = viewport.getBoundingClientRect()
70
+ updateViewportSize(bounds.width, bounds.height)
71
+ if (typeof ResizeObserver !== 'function') return
72
+
73
+ resizeObserver = new ResizeObserver((entries) => {
74
+ const entry = entries.find(candidate => candidate.target === viewport)
75
+ if (entry) updateViewportSize(entry.contentRect.width, entry.contentRect.height)
76
+ })
77
+ resizeObserver.observe(viewport)
78
+ })
79
+
80
+ $effect(() => {
81
+ void svg
82
+ if (!svgHost || renderedWidth === null || renderedHeight === null) return
83
+
84
+ const element = svgHost.querySelector('svg') as SVGSVGElement | null
85
+ if (!element) return
86
+ element.style.display = 'block'
87
+ element.style.width = `${renderedWidth}px`
88
+ element.style.height = `${renderedHeight}px`
89
+ element.style.maxWidth = 'none'
90
+ })
91
+
92
+ onDestroy(() => {
93
+ resizeObserver?.disconnect()
94
+ resizeObserver = undefined
95
+ })
96
+
97
+ function zoomIn() {
98
+ zoom = zoomMermaidIn(zoom, fitScale)
99
+ }
100
+
101
+ function zoomOut() {
102
+ zoom = zoomMermaidOut(zoom, fitScale)
103
+ }
104
+
105
+ function resetZoom() {
106
+ zoom = resetMermaidZoom()
107
+ }
108
+
109
+ function fitToWindow() {
110
+ zoom = FIT_MERMAID_ZOOM
111
+ }
112
+
113
+ function handleKeydown(event: KeyboardEvent): boolean | void {
114
+ if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) return
115
+
116
+ if (event.key === '+' || event.key === '=') {
117
+ if (canZoomMermaidIn(zoom, fitScale)) zoomIn()
118
+ } else if (event.key === '-') {
119
+ if (canZoomMermaidOut(zoom, fitScale)) zoomOut()
120
+ } else if (event.key === '0') {
121
+ resetZoom()
122
+ } else if (event.key.toLowerCase() === 'f') {
123
+ fitToWindow()
124
+ } else {
125
+ return
126
+ }
127
+
128
+ event.preventDefault()
129
+ return true
130
+ }
131
+ </script>
132
+
133
+ <Modal
134
+ {onClose}
135
+ ariaLabel="Mermaid diagram preview"
136
+ showHeader={false}
137
+ maxWidth="calc(100vw - 2rem)"
138
+ boxClass="mermaid-diagram-preview h-[calc(100vh-2rem)] !max-h-[calc(100vh-2rem)] w-[calc(100vw-2rem)]"
139
+ initialFocus={() => closeButton}
140
+ onKeydown={handleKeydown}
141
+ >
142
+ <div class="flex min-h-0 flex-1 flex-col bg-base-300/40">
143
+ <header class="mermaid-diagram-preview-toolbar flex min-h-14 shrink-0 items-center gap-2 border-b border-base-300 bg-base-100 px-4 py-2">
144
+ <h2 class="m-0 min-w-0 flex-1 truncate text-sm font-semibold text-base-content">Mermaid diagram preview</h2>
145
+
146
+ <div class="flex items-center gap-1" role="group" aria-label="Diagram zoom controls">
147
+ <button
148
+ type="button"
149
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
150
+ aria-label="Zoom out"
151
+ title="Zoom out (-)"
152
+ disabled={!canZoomMermaidOut(zoom, fitScale)}
153
+ onclick={zoomOut}
154
+ >
155
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
156
+ <circle cx="11" cy="11" r="8" />
157
+ <path d="m21 21-4.3-4.3M8 11h6" />
158
+ </svg>
159
+ </button>
160
+
161
+ <output class="min-w-20 text-center text-xs tabular-nums text-base-content/70" aria-live="polite">{zoomLabel}</output>
162
+
163
+ <button
164
+ type="button"
165
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
166
+ aria-label="Zoom in"
167
+ title="Zoom in (+)"
168
+ disabled={!canZoomMermaidIn(zoom, fitScale)}
169
+ onclick={zoomIn}
170
+ >
171
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
172
+ <circle cx="11" cy="11" r="8" />
173
+ <path d="m21 21-4.3-4.3M11 8v6M8 11h6" />
174
+ </svg>
175
+ </button>
176
+
177
+ <button
178
+ type="button"
179
+ class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
180
+ aria-label="Reset zoom to 100%"
181
+ title="Reset zoom to 100% (0)"
182
+ onclick={resetZoom}
183
+ >100%</button>
184
+
185
+ <button
186
+ type="button"
187
+ class="btn btn-ghost btn-sm h-11 min-h-11 px-3"
188
+ aria-label="Fit diagram to window"
189
+ aria-pressed={zoom.mode === 'fit'}
190
+ title="Fit diagram to window (F)"
191
+ onclick={fitToWindow}
192
+ >Fit</button>
193
+ </div>
194
+
195
+ <button
196
+ bind:this={closeButton}
197
+ type="button"
198
+ class="btn btn-ghost btn-sm h-11 min-h-11 w-11 p-0"
199
+ aria-label="Close diagram preview"
200
+ onclick={onClose}
201
+ >
202
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
203
+ <path d="M18 6 6 18M6 6l12 12" />
204
+ </svg>
205
+ </button>
206
+ </header>
207
+
208
+ <div bind:this={viewport} data-testid="mermaid-preview-viewport" class="mermaid-diagram-preview-viewport min-h-0 flex-1 overflow-auto p-4">
209
+ <div data-testid="mermaid-preview-canvas" class="mermaid-diagram-preview-canvas flex h-max min-h-full w-max min-w-full items-center justify-center">
210
+ <div bind:this={svgHost} class="shrink-0">
211
+ {@html svg}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </Modal>
@@ -179,7 +179,12 @@
179
179
  {#if header}
180
180
  {@render header()}
181
181
  {/if}
182
- <button class="btn btn-ghost btn-xs shrink-0" aria-label={closeLabel} onclick={handleCloseButtonClick} type="button" disabled={closeDisabled}>✕</button>
182
+ <button class="btn btn-ghost h-11 min-h-11 w-11 min-w-11 shrink-0 p-0" aria-label={closeLabel} onclick={handleCloseButtonClick} type="button" disabled={closeDisabled}>
183
+ <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
184
+ <path d="M18 6 6 18" />
185
+ <path d="m6 6 12 12" />
186
+ </svg>
187
+ </button>
183
188
  </div>
184
189
  {/if}
185
190
  {@render children()}
@@ -23,17 +23,12 @@
23
23
  class: className,
24
24
  }: Props = $props()
25
25
 
26
- function handleKeydown(event: KeyboardEvent): void {
27
- if (event.key !== 'Enter' && event.key !== ' ') return
28
- event.preventDefault()
29
- onActivate()
30
- }
31
26
  </script>
32
27
 
33
28
  <button
34
29
  type="button"
35
30
  class={[
36
- 'relative mx-2 flex min-h-11 w-auto items-center rounded-lg gap-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
31
+ 'relative mx-2 flex min-h-11 w-[calc(100%_-_1rem)] items-center rounded-lg gap-3 py-2.5 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
37
32
  collapsed ? 'justify-center px-0' : 'px-3',
38
33
  active ? 'bg-primary/10 text-primary' : 'text-base-content/55 hover:bg-base-200 hover:text-base-content',
39
34
  className,
@@ -42,7 +37,6 @@
42
37
  aria-label={accessibleName}
43
38
  aria-current={active ? 'page' : undefined}
44
39
  onclick={onActivate}
45
- onkeydown={handleKeydown}
46
40
  >
47
41
  {#if leading}
48
42
  <span class="relative shrink-0">{@render leading()}</span>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <path d="M24,6l2,6H22L20,6H17l2,6H15L13,6H10l2,6H8L6,6H5A3,3,0,0,0,2,9V23a3,3,0,0,0,3,3H27a3,3,0,0,0,3-3V6Z" style="fill: #ff9800"/>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "@openforge-app/plugin-sdk",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
+ "description": "TypeScript SDK for building trusted OpenForge frontend and backend plugins.",
5
+ "homepage": "https://github.com/koenvg/openforge/blob/main/docs/plugin-authoring.md",
6
+ "keywords": [
7
+ "openforge",
8
+ "plugin",
9
+ "sdk",
10
+ "svelte"
11
+ ],
4
12
  "license": "MIT",
5
13
  "repository": {
6
14
  "type": "git",
@@ -94,15 +102,16 @@
94
102
  "access": "public"
95
103
  },
96
104
  "dependencies": {
97
- "dompurify": "^3.4.13",
98
- "marked": "^18.0.10"
105
+ "dompurify": "^3.4.14",
106
+ "marked": "^18.0.11",
107
+ "mermaid": "^11.17.2"
99
108
  },
100
109
  "peerDependencies": {
101
110
  "svelte": "^5.0.0"
102
111
  },
103
112
  "devDependencies": {
104
- "@types/node": "26.2.0",
105
- "svelte": "5.56.9",
113
+ "@types/node": "26.3.0",
114
+ "svelte": "5.56.10",
106
115
  "semver": "^7.8.5",
107
116
  "typescript": "^7.0.2",
108
117
  "vitest": "^4.1.11",