backpack-viewer 0.6.0 → 0.7.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.
Files changed (54) hide show
  1. package/bin/serve.js +159 -396
  2. package/dist/app/assets/index-D-H7agBH.js +12 -0
  3. package/dist/app/assets/index-DE73ngo-.css +1 -0
  4. package/dist/app/assets/index-Lvl7EMM_.js +6 -0
  5. package/dist/app/index.html +2 -2
  6. package/dist/bridge.d.ts +22 -0
  7. package/dist/bridge.js +41 -0
  8. package/dist/config.js +10 -0
  9. package/dist/copy-prompt.d.ts +17 -0
  10. package/dist/copy-prompt.js +81 -0
  11. package/dist/default-config.json +4 -0
  12. package/dist/dom-utils.d.ts +46 -0
  13. package/dist/dom-utils.js +57 -0
  14. package/dist/empty-state.js +63 -31
  15. package/dist/extensions/api.d.ts +15 -0
  16. package/dist/extensions/api.js +185 -0
  17. package/dist/extensions/chat/backpack-extension.json +23 -0
  18. package/dist/extensions/chat/src/index.js +32 -0
  19. package/dist/extensions/chat/src/panel.js +306 -0
  20. package/dist/extensions/chat/src/providers/anthropic.js +158 -0
  21. package/dist/extensions/chat/src/providers/types.js +15 -0
  22. package/dist/extensions/chat/src/tools.js +281 -0
  23. package/dist/extensions/chat/style.css +147 -0
  24. package/dist/extensions/event-bus.d.ts +12 -0
  25. package/dist/extensions/event-bus.js +30 -0
  26. package/dist/extensions/loader.d.ts +32 -0
  27. package/dist/extensions/loader.js +71 -0
  28. package/dist/extensions/manifest.d.ts +54 -0
  29. package/dist/extensions/manifest.js +116 -0
  30. package/dist/extensions/panel-mount.d.ts +26 -0
  31. package/dist/extensions/panel-mount.js +377 -0
  32. package/dist/extensions/share/backpack-extension.json +20 -0
  33. package/dist/extensions/share/src/index.js +357 -0
  34. package/dist/extensions/share/style.css +151 -0
  35. package/dist/extensions/taskbar.d.ts +29 -0
  36. package/dist/extensions/taskbar.js +64 -0
  37. package/dist/extensions/types.d.ts +182 -0
  38. package/dist/extensions/types.js +8 -0
  39. package/dist/info-panel.d.ts +2 -1
  40. package/dist/info-panel.js +78 -87
  41. package/dist/main.js +189 -29
  42. package/dist/search.js +1 -1
  43. package/dist/server-api-routes.d.ts +56 -0
  44. package/dist/server-api-routes.js +460 -0
  45. package/dist/server-extensions.d.ts +126 -0
  46. package/dist/server-extensions.js +272 -0
  47. package/dist/server-viewer-state.d.ts +18 -0
  48. package/dist/server-viewer-state.js +33 -0
  49. package/dist/sidebar.js +19 -7
  50. package/dist/style.css +356 -74
  51. package/dist/tools-pane.js +31 -14
  52. package/package.json +4 -3
  53. package/dist/app/assets/index-B3z5bBGl.css +0 -1
  54. package/dist/app/assets/index-BROJmzot.js +0 -35
@@ -0,0 +1,116 @@
1
+ import { VIEWER_API_VERSION } from "./types.js";
2
+ export class ManifestError extends Error {
3
+ constructor(extensionPath, problem) {
4
+ super(`Invalid manifest at ${extensionPath}: ${problem}`);
5
+ this.name = "ManifestError";
6
+ }
7
+ }
8
+ /**
9
+ * Validate a parsed manifest object. Throws ManifestError on invalid input.
10
+ * Returns the typed manifest. Stricter than tsc since the file comes from
11
+ * disk; we want clear errors at load time, not silent misbehavior later.
12
+ */
13
+ export function validateManifest(raw, extensionPath) {
14
+ if (!raw || typeof raw !== "object") {
15
+ throw new ManifestError(extensionPath, "must be a JSON object");
16
+ }
17
+ const obj = raw;
18
+ function requireString(field) {
19
+ const v = obj[field];
20
+ if (typeof v !== "string" || !v) {
21
+ throw new ManifestError(extensionPath, `field "${field}" must be a non-empty string`);
22
+ }
23
+ return v;
24
+ }
25
+ const name = requireString("name");
26
+ const version = requireString("version");
27
+ const viewerApi = requireString("viewerApi");
28
+ const entry = requireString("entry");
29
+ if (viewerApi !== VIEWER_API_VERSION) {
30
+ throw new ManifestError(extensionPath, `viewerApi "${viewerApi}" is not supported (this viewer supports "${VIEWER_API_VERSION}")`);
31
+ }
32
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(name)) {
33
+ throw new ManifestError(extensionPath, `name "${name}" must be lowercase with hyphens/underscores only — used as a URL path segment and a directory name`);
34
+ }
35
+ // Optional fields
36
+ const displayName = typeof obj.displayName === "string" ? obj.displayName : undefined;
37
+ const description = typeof obj.description === "string" ? obj.description : undefined;
38
+ const stylesheet = typeof obj.stylesheet === "string" ? obj.stylesheet : undefined;
39
+ let permissions;
40
+ if (obj.permissions !== undefined) {
41
+ if (!obj.permissions || typeof obj.permissions !== "object") {
42
+ throw new ManifestError(extensionPath, "permissions must be an object");
43
+ }
44
+ permissions = validatePermissions(obj.permissions, extensionPath);
45
+ }
46
+ return { name, version, viewerApi, displayName, description, entry, stylesheet, permissions };
47
+ }
48
+ function validatePermissions(raw, extensionPath) {
49
+ const out = {};
50
+ if (raw.graph !== undefined) {
51
+ if (!Array.isArray(raw.graph)) {
52
+ throw new ManifestError(extensionPath, "permissions.graph must be an array");
53
+ }
54
+ out.graph = raw.graph.filter((v) => v === "read" || v === "write");
55
+ }
56
+ if (raw.viewer !== undefined) {
57
+ if (!Array.isArray(raw.viewer)) {
58
+ throw new ManifestError(extensionPath, "permissions.viewer must be an array");
59
+ }
60
+ out.viewer = raw.viewer.filter((v) => v === "focus" || v === "pan");
61
+ }
62
+ if (raw.settings !== undefined) {
63
+ out.settings = raw.settings === true;
64
+ }
65
+ if (raw.network !== undefined) {
66
+ if (!Array.isArray(raw.network)) {
67
+ throw new ManifestError(extensionPath, "permissions.network must be an array");
68
+ }
69
+ out.network = raw.network.map((entry, i) => validateNetworkEntry(entry, i, extensionPath));
70
+ }
71
+ return out;
72
+ }
73
+ function validateNetworkEntry(raw, index, extensionPath) {
74
+ if (!raw || typeof raw !== "object") {
75
+ throw new ManifestError(extensionPath, `permissions.network[${index}] must be an object with { origin, injectHeaders? }`);
76
+ }
77
+ const obj = raw;
78
+ const origin = obj.origin;
79
+ if (typeof origin !== "string" || !origin) {
80
+ throw new ManifestError(extensionPath, `permissions.network[${index}].origin must be a non-empty string`);
81
+ }
82
+ // Reject obviously bad shapes early
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(origin);
86
+ }
87
+ catch {
88
+ throw new ManifestError(extensionPath, `permissions.network[${index}].origin "${origin}" is not a valid URL`);
89
+ }
90
+ if (parsed.pathname !== "/" && parsed.pathname !== "") {
91
+ throw new ManifestError(extensionPath, `permissions.network[${index}].origin must be a bare origin (no path); got "${origin}"`);
92
+ }
93
+ let injectHeaders;
94
+ if (obj.injectHeaders !== undefined) {
95
+ if (!obj.injectHeaders || typeof obj.injectHeaders !== "object") {
96
+ throw new ManifestError(extensionPath, `permissions.network[${index}].injectHeaders must be an object`);
97
+ }
98
+ injectHeaders = {};
99
+ for (const [headerName, value] of Object.entries(obj.injectHeaders)) {
100
+ if (!value || typeof value !== "object") {
101
+ throw new ManifestError(extensionPath, `injectHeaders.${headerName} must be an object`);
102
+ }
103
+ const v = value;
104
+ if (typeof v.fromEnv === "string") {
105
+ injectHeaders[headerName] = { fromEnv: v.fromEnv };
106
+ }
107
+ else if (typeof v.literal === "string") {
108
+ injectHeaders[headerName] = { literal: v.literal };
109
+ }
110
+ else {
111
+ throw new ManifestError(extensionPath, `injectHeaders.${headerName} must have either fromEnv (string) or literal (string)`);
112
+ }
113
+ }
114
+ }
115
+ return { origin: parsed.origin, injectHeaders };
116
+ }
@@ -0,0 +1,26 @@
1
+ import type { MountPanelOptions, MountedPanel } from "./types";
2
+ /**
3
+ * Panel mount surface — wraps an extension-provided body element with
4
+ * standard chrome (title, custom buttons, fullscreen toggle, close X)
5
+ * and makes it draggable + click-to-front + position-persistent.
6
+ *
7
+ * Layout policy:
8
+ * - Each panel positions itself via inline left/top from JS, so
9
+ * drag-to-move is just updating those properties.
10
+ * - The user grabs the title bar to move the panel anywhere within
11
+ * the canvas container. Bounds-clamped so the title bar stays
12
+ * visible.
13
+ * - Click anywhere on a panel raises it above all other panels via
14
+ * a shared z-index counter. The counter is module-global so
15
+ * info-panel and extension panels share the same focus stack.
16
+ * - Position + fullscreen state are persisted to localStorage per
17
+ * panel key, so dragging + maximize survive page refreshes.
18
+ *
19
+ * No region/dock vocabulary, no automatic collision avoidance — if
20
+ * two panels overlap, the user drags one out of the way. The user is
21
+ * the layout coordinator.
22
+ */
23
+ export interface PanelMount {
24
+ mount(extName: string, body: HTMLElement, opts?: MountPanelOptions): MountedPanel;
25
+ }
26
+ export declare function createPanelMount(parent: HTMLElement): PanelMount;
@@ -0,0 +1,377 @@
1
+ import { makeSvgIcon } from "../dom-utils";
2
+ const PANEL_TIER_BASE = 30; // matches --z-panel
3
+ const PANEL_TIER_MAX = 39; // last value before --z-floating (40); reset above this
4
+ const FULLSCREEN_Z = 45; // matches --z-panel-fullscreen — above secondary floating, below floating-primary (search bar)
5
+ const DEFAULT_PANEL_WIDTH = 380;
6
+ const DEFAULT_TOP_OFFSET = 70;
7
+ const DEFAULT_RIGHT_MARGIN = 16;
8
+ const MIN_VISIBLE_AFTER_DRAG = 80;
9
+ const STORAGE_PREFIX = "backpack-viewer:panel:";
10
+ const FULLSCREEN_PARENT_CLASS = "has-fullscreen-panel";
11
+ /**
12
+ * Module-global click-to-front counter shared by all panels (info-panel
13
+ * AND extension panels). Click any panel and it bumps it above the
14
+ * other panels in the panel tier. Reset on page reload.
15
+ *
16
+ * If the counter ever exceeds PANEL_TIER_MAX (e.g., the user clicks
17
+ * many panels back and forth), all panels reset to base+1 and the
18
+ * counter resumes — this prevents panels from leaking into the
19
+ * floating tier and overlapping the top bar.
20
+ */
21
+ let topZ = PANEL_TIER_BASE;
22
+ function bringPanelToFront(panel) {
23
+ topZ++;
24
+ if (topZ > PANEL_TIER_MAX) {
25
+ // Compress: reset every panel in the layer to base+1, then
26
+ // promote this one. Counter resumes from base+2.
27
+ document.querySelectorAll(".extension-panel").forEach((p) => {
28
+ p.style.zIndex = String(PANEL_TIER_BASE + 1);
29
+ });
30
+ topZ = PANEL_TIER_BASE + 2;
31
+ }
32
+ panel.style.zIndex = String(topZ);
33
+ }
34
+ /**
35
+ * Ref-count of panels currently in fullscreen mode. The has-fullscreen
36
+ * class is added to #canvas-container while count > 0 and removed when
37
+ * it drops to 0. CSS uses the class to hide secondary top-bar controls
38
+ * (zoom, theme, copy-prompt, sidebar-expand, taskbar slots) so they
39
+ * don't visually conflict with the fullscreen panel's chrome buttons.
40
+ */
41
+ let fullscreenCount = 0;
42
+ function notifyFullscreenChange(parent, entering) {
43
+ // Walk up to #canvas-container — that's where the class lives.
44
+ // If parent is the canvas container itself, use it directly.
45
+ const target = parent.id === "canvas-container"
46
+ ? parent
47
+ : parent.closest("#canvas-container") ?? parent;
48
+ if (entering) {
49
+ fullscreenCount++;
50
+ }
51
+ else {
52
+ fullscreenCount = Math.max(0, fullscreenCount - 1);
53
+ }
54
+ target.classList.toggle(FULLSCREEN_PARENT_CLASS, fullscreenCount > 0);
55
+ }
56
+ function loadPersistedState(key) {
57
+ try {
58
+ const raw = localStorage.getItem(STORAGE_PREFIX + key);
59
+ if (!raw)
60
+ return null;
61
+ const parsed = JSON.parse(raw);
62
+ if (parsed && typeof parsed === "object")
63
+ return parsed;
64
+ }
65
+ catch {
66
+ /* ignore — best effort */
67
+ }
68
+ return null;
69
+ }
70
+ function savePersistedState(key, state) {
71
+ try {
72
+ localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(state));
73
+ }
74
+ catch {
75
+ /* ignore — quota exceeded etc */
76
+ }
77
+ }
78
+ /** Build a fullscreen-toggle SVG icon (4 corner arrows). */
79
+ function makeFullscreenIcon() {
80
+ return makeSvgIcon({ size: 13, strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }, [
81
+ { tag: "polyline", attrs: { points: "4 9 4 4 9 4" } },
82
+ { tag: "polyline", attrs: { points: "20 9 20 4 15 4" } },
83
+ { tag: "polyline", attrs: { points: "4 15 4 20 9 20" } },
84
+ { tag: "polyline", attrs: { points: "20 15 20 20 15 20" } },
85
+ ]);
86
+ }
87
+ /** Build a restore-from-fullscreen SVG icon (4 inward arrows). */
88
+ function makeRestoreIcon() {
89
+ return makeSvgIcon({ size: 13, strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round" }, [
90
+ { tag: "polyline", attrs: { points: "9 4 9 9 4 9" } },
91
+ { tag: "polyline", attrs: { points: "15 4 15 9 20 9" } },
92
+ { tag: "polyline", attrs: { points: "9 20 9 15 4 15" } },
93
+ { tag: "polyline", attrs: { points: "15 20 15 15 20 15" } },
94
+ ]);
95
+ }
96
+ export function createPanelMount(parent) {
97
+ const layer = document.createElement("div");
98
+ layer.className = "extension-panel-layer";
99
+ parent.appendChild(layer);
100
+ function defaultPosition() {
101
+ const parentRect = parent.getBoundingClientRect();
102
+ const left = Math.max(0, parentRect.width - DEFAULT_PANEL_WIDTH - DEFAULT_RIGHT_MARGIN);
103
+ return { left, top: DEFAULT_TOP_OFFSET };
104
+ }
105
+ function clampPosition(pos, panelRect) {
106
+ const parentRect = parent.getBoundingClientRect();
107
+ const minLeft = MIN_VISIBLE_AFTER_DRAG - panelRect.width;
108
+ const maxLeft = parentRect.width - MIN_VISIBLE_AFTER_DRAG;
109
+ const minTop = 0;
110
+ const maxTop = parentRect.height - 40;
111
+ return {
112
+ left: Math.max(minLeft, Math.min(maxLeft, pos.left)),
113
+ top: Math.max(minTop, Math.min(maxTop, pos.top)),
114
+ };
115
+ }
116
+ function mount(extName, body, opts = {}) {
117
+ const persistKey = opts.persistKey ?? extName;
118
+ const persisted = loadPersistedState(persistKey);
119
+ const root = document.createElement("aside");
120
+ root.className = "extension-panel";
121
+ root.dataset.panel = persistKey;
122
+ // --- Header (drag handle + chrome) ---
123
+ const header = document.createElement("div");
124
+ header.className = "extension-panel-header";
125
+ const titleEl = document.createElement("span");
126
+ titleEl.className = "extension-panel-title";
127
+ titleEl.textContent = opts.title ?? extName;
128
+ header.appendChild(titleEl);
129
+ // Custom header buttons get their own container so setHeaderButtons
130
+ // can replace its children without disturbing the built-in controls.
131
+ const customBtnContainer = document.createElement("div");
132
+ customBtnContainer.className = "extension-panel-custom-btns";
133
+ header.appendChild(customBtnContainer);
134
+ function renderCustomButtons(buttons) {
135
+ customBtnContainer.replaceChildren();
136
+ for (const spec of buttons) {
137
+ const btn = document.createElement("button");
138
+ btn.className = "extension-panel-btn";
139
+ btn.title = spec.label;
140
+ btn.setAttribute("aria-label", spec.label);
141
+ btn.textContent = spec.iconText ?? spec.label;
142
+ if (spec.disabled)
143
+ btn.disabled = true;
144
+ btn.addEventListener("click", (e) => {
145
+ e.stopPropagation();
146
+ try {
147
+ spec.onClick();
148
+ }
149
+ catch (err) {
150
+ console.error(`[backpack-viewer] panel header button "${spec.label}" threw:`, err);
151
+ }
152
+ });
153
+ // Buttons live inside the header (which is the drag handle), so
154
+ // mousedown must not start a drag.
155
+ btn.addEventListener("mousedown", (e) => e.stopPropagation());
156
+ customBtnContainer.appendChild(btn);
157
+ }
158
+ }
159
+ renderCustomButtons(opts.headerButtons ?? []);
160
+ // --- Built-in fullscreen + close buttons ---
161
+ let fullscreen = false;
162
+ let fullscreenBtn = null;
163
+ let fullscreenIconEl = null;
164
+ if (opts.showFullscreenButton !== false) {
165
+ fullscreenBtn = document.createElement("button");
166
+ fullscreenBtn.className = "extension-panel-btn extension-panel-btn-fullscreen";
167
+ fullscreenBtn.title = "Toggle fullscreen";
168
+ fullscreenBtn.setAttribute("aria-label", "Toggle fullscreen");
169
+ fullscreenIconEl = makeFullscreenIcon();
170
+ fullscreenBtn.appendChild(fullscreenIconEl);
171
+ fullscreenBtn.addEventListener("click", (e) => {
172
+ e.stopPropagation();
173
+ setFullscreen(!fullscreen);
174
+ });
175
+ fullscreenBtn.addEventListener("mousedown", (e) => e.stopPropagation());
176
+ header.appendChild(fullscreenBtn);
177
+ }
178
+ let closeBtn = null;
179
+ if (opts.showCloseButton !== false) {
180
+ closeBtn = document.createElement("button");
181
+ closeBtn.className = "extension-panel-btn extension-panel-btn-close";
182
+ closeBtn.title = "Close panel";
183
+ closeBtn.setAttribute("aria-label", "Close panel");
184
+ closeBtn.textContent = "\u00d7";
185
+ closeBtn.addEventListener("click", (e) => {
186
+ e.stopPropagation();
187
+ if (opts.hideOnClose) {
188
+ setVisible(false);
189
+ // Fire onClose for hideOnClose panels too — owners may want
190
+ // to reset state.
191
+ try {
192
+ opts.onClose?.();
193
+ }
194
+ catch (err) {
195
+ console.error("[backpack-viewer] panel onClose threw:", err);
196
+ }
197
+ }
198
+ else {
199
+ close();
200
+ }
201
+ });
202
+ closeBtn.addEventListener("mousedown", (e) => e.stopPropagation());
203
+ header.appendChild(closeBtn);
204
+ }
205
+ root.appendChild(header);
206
+ // --- Body ---
207
+ const bodyWrap = document.createElement("div");
208
+ bodyWrap.className = "extension-panel-body";
209
+ bodyWrap.appendChild(body);
210
+ root.appendChild(bodyWrap);
211
+ layer.appendChild(root);
212
+ // --- Initial position ---
213
+ // Priority: persisted > opts.defaultPosition > computed default
214
+ const initial = (persisted && persisted.left != null && persisted.top != null)
215
+ ? { left: persisted.left, top: persisted.top }
216
+ : (opts.defaultPosition ?? defaultPosition());
217
+ // Bounds-clamp the restored position in case the viewport shrank
218
+ // since it was saved (otherwise the panel could end up off-screen
219
+ // and unrecoverable).
220
+ const initialRect = { width: DEFAULT_PANEL_WIDTH, height: 200 };
221
+ const clampedInitial = clampPosition(initial, initialRect);
222
+ root.style.left = clampedInitial.left + "px";
223
+ root.style.top = clampedInitial.top + "px";
224
+ bringPanelToFront(root);
225
+ // --- Drag handling ---
226
+ let dragStartX = 0;
227
+ let dragStartY = 0;
228
+ let panelStartLeft = 0;
229
+ let panelStartTop = 0;
230
+ let dragging = false;
231
+ function onMouseMove(e) {
232
+ if (!dragging)
233
+ return;
234
+ const dx = e.clientX - dragStartX;
235
+ const dy = e.clientY - dragStartY;
236
+ const next = clampPosition({ left: panelStartLeft + dx, top: panelStartTop + dy }, root.getBoundingClientRect());
237
+ root.style.left = next.left + "px";
238
+ root.style.top = next.top + "px";
239
+ }
240
+ function onMouseUp() {
241
+ if (!dragging)
242
+ return;
243
+ dragging = false;
244
+ document.removeEventListener("mousemove", onMouseMove);
245
+ document.removeEventListener("mouseup", onMouseUp);
246
+ // Persist the new position
247
+ const rect = root.getBoundingClientRect();
248
+ const parentRect = parent.getBoundingClientRect();
249
+ savePersistedState(persistKey, {
250
+ ...loadPersistedState(persistKey),
251
+ left: rect.left - parentRect.left,
252
+ top: rect.top - parentRect.top,
253
+ });
254
+ }
255
+ header.addEventListener("mousedown", (e) => {
256
+ if (fullscreen)
257
+ return; // can't drag while fullscreen
258
+ // Buttons inside the header stop propagation already, so we know
259
+ // this came from the title area or empty space.
260
+ dragging = true;
261
+ dragStartX = e.clientX;
262
+ dragStartY = e.clientY;
263
+ const rect = root.getBoundingClientRect();
264
+ const parentRect = parent.getBoundingClientRect();
265
+ panelStartLeft = rect.left - parentRect.left;
266
+ panelStartTop = rect.top - parentRect.top;
267
+ bringPanelToFront(root);
268
+ document.addEventListener("mousemove", onMouseMove);
269
+ document.addEventListener("mouseup", onMouseUp);
270
+ e.preventDefault();
271
+ });
272
+ // Click anywhere on the panel raises it.
273
+ root.addEventListener("mousedown", () => {
274
+ if (!fullscreen)
275
+ bringPanelToFront(root);
276
+ }, { capture: true });
277
+ // --- Lifecycle ---
278
+ let closed = false;
279
+ let visible = true;
280
+ function close() {
281
+ if (closed)
282
+ return;
283
+ closed = true;
284
+ document.removeEventListener("mousemove", onMouseMove);
285
+ document.removeEventListener("mouseup", onMouseUp);
286
+ // If this panel was in fullscreen, decrement the global counter
287
+ // so the parent class clears properly.
288
+ if (fullscreen) {
289
+ notifyFullscreenChange(parent, false);
290
+ fullscreen = false;
291
+ }
292
+ root.remove();
293
+ try {
294
+ opts.onClose?.();
295
+ }
296
+ catch (err) {
297
+ console.error("[backpack-viewer] panel onClose threw:", err);
298
+ }
299
+ }
300
+ function setVisible(value) {
301
+ if (value === visible)
302
+ return;
303
+ visible = value;
304
+ root.classList.toggle("is-hidden", !visible);
305
+ if (visible) {
306
+ bringPanelToFront(root);
307
+ // If we were fullscreen when hidden, the global counter was
308
+ // decremented; restore it now that we're visible again.
309
+ if (fullscreen) {
310
+ notifyFullscreenChange(parent, true);
311
+ }
312
+ }
313
+ else if (fullscreen) {
314
+ // Hidden while fullscreen — decrement the global counter so
315
+ // other panels and the top bar reappear.
316
+ notifyFullscreenChange(parent, false);
317
+ }
318
+ }
319
+ function setFullscreen(value) {
320
+ if (value === fullscreen)
321
+ return;
322
+ fullscreen = value;
323
+ root.classList.toggle("is-fullscreen", fullscreen);
324
+ // Swap the icon between the fullscreen-out and restore-in glyphs
325
+ if (fullscreenBtn && fullscreenIconEl) {
326
+ fullscreenIconEl.remove();
327
+ fullscreenIconEl = fullscreen ? makeRestoreIcon() : makeFullscreenIcon();
328
+ fullscreenBtn.appendChild(fullscreenIconEl);
329
+ }
330
+ // Bump z-index above secondary floating tier when fullscreen so
331
+ // the panel covers zoom/theme/copy-prompt; restore to the
332
+ // panel-tier counter when not. The search bar lives at
333
+ // --z-floating-primary above this and stays visible.
334
+ if (fullscreen) {
335
+ root.style.zIndex = String(FULLSCREEN_Z);
336
+ }
337
+ else {
338
+ bringPanelToFront(root);
339
+ }
340
+ // Toggle the parent class so CSS hides secondary controls
341
+ notifyFullscreenChange(parent, fullscreen);
342
+ // Persist
343
+ savePersistedState(persistKey, {
344
+ ...loadPersistedState(persistKey),
345
+ fullscreen,
346
+ });
347
+ try {
348
+ opts.onFullscreenChange?.(fullscreen);
349
+ }
350
+ catch (err) {
351
+ console.error("[backpack-viewer] panel onFullscreenChange threw:", err);
352
+ }
353
+ }
354
+ // Restore fullscreen state if persisted
355
+ if (persisted?.fullscreen) {
356
+ setFullscreen(true);
357
+ }
358
+ function setTitle(t) {
359
+ titleEl.textContent = t;
360
+ }
361
+ function setHeaderButtons(buttons) {
362
+ renderCustomButtons(buttons);
363
+ }
364
+ return {
365
+ close,
366
+ setFullscreen,
367
+ isFullscreen: () => fullscreen,
368
+ setTitle,
369
+ setHeaderButtons,
370
+ setVisible,
371
+ isVisible: () => visible,
372
+ bringToFront: () => bringPanelToFront(root),
373
+ element: body,
374
+ };
375
+ }
376
+ return { mount };
377
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "share",
3
+ "version": "0.1.0",
4
+ "viewerApi": "1",
5
+ "displayName": "Share",
6
+ "description": "Share encrypted backpacks via a link. Recipients decrypt in-browser — the server never sees your data.",
7
+ "entry": "src/index.js",
8
+ "stylesheet": "style.css",
9
+ "permissions": {
10
+ "graph": ["read"],
11
+ "viewer": [],
12
+ "settings": true,
13
+ "network": [
14
+ {
15
+ "origin": "https://app.backpackontology.com",
16
+ "description": "Share relay for uploading encrypted backpacks"
17
+ }
18
+ ]
19
+ }
20
+ }