@scenography/overlay-presets 0.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,890 @@
1
+ import { resolveText } from "@scenography/overlay";
2
+ //#region src/lucide.ts
3
+ /**
4
+ * Render a Lucide icon by name as overlay button content.
5
+ *
6
+ * Lucide is an OPTIONAL peer dependency: it's imported lazily, so the overlay
7
+ * works without it. When lucide isn't installed the icon renders empty — install
8
+ * `lucide`, pass your own `icon` to a prefab, or use `actionButton` if you don't
9
+ * want the dependency.
10
+ */
11
+ function lucideIcon(name, size = 18) {
12
+ return () => {
13
+ const span = document.createElement("span");
14
+ span.style.display = "inline-flex";
15
+ import("lucide").then((lucide) => {
16
+ const node = lucide[name];
17
+ if (node === void 0) return;
18
+ const svg = lucide.createElement(node);
19
+ svg.setAttribute("width", String(size));
20
+ svg.setAttribute("height", String(size));
21
+ span.replaceChildren(svg);
22
+ }).catch(() => {});
23
+ return span;
24
+ };
25
+ }
26
+ //#endregion
27
+ //#region src/util.ts
28
+ /** Replace an element's content with an icon/glyph (string → text, node → child). */
29
+ function setIcon(el, content) {
30
+ const value = typeof content === "function" ? content() : content;
31
+ el.replaceChildren();
32
+ if (typeof value === "string") el.textContent = value;
33
+ else el.appendChild(value);
34
+ }
35
+ /**
36
+ * Run `apply` once and re-run it whenever the overlay's locale changes, so a
37
+ * component's `Text` values stay localized live. Returns an unsubscribe to call
38
+ * on teardown (a no-op when no locale source is configured).
39
+ */
40
+ function bindLocale(ctx, apply) {
41
+ apply();
42
+ return ctx.locale?.subscribe(apply) ?? (() => {});
43
+ }
44
+ //#endregion
45
+ //#region src/components/fullscreen/fullscreen.ts
46
+ /**
47
+ * A fullscreen toggle. daisyUI prefab (styled with `btn`). Defaults to plain
48
+ * glyphs and pulls in no icon library; pass `icons` (e.g. via `lucideIcon` or
49
+ * your own SVGs) for richer iconography.
50
+ */
51
+ function fullscreenButton(options = {}) {
52
+ const enter = options.icons?.enter ?? "⛶";
53
+ const exit = options.icons?.exit ?? "✕";
54
+ return (ctx) => {
55
+ const button = document.createElement("button");
56
+ button.type = "button";
57
+ button.className = options.className ?? "btn btn-circle btn-sm";
58
+ const sync = () => {
59
+ const active = document.fullscreenElement !== null;
60
+ setIcon(button, active ? exit : enter);
61
+ button.setAttribute("aria-label", active ? "Exit fullscreen" : "Enter fullscreen");
62
+ };
63
+ const onClick = () => {
64
+ if (document.fullscreenElement) document.exitFullscreen();
65
+ else ctx.surface.requestFullscreen?.();
66
+ };
67
+ button.addEventListener("click", onClick);
68
+ document.addEventListener("fullscreenchange", sync);
69
+ sync();
70
+ return {
71
+ element: button,
72
+ destroy() {
73
+ button.removeEventListener("click", onClick);
74
+ document.removeEventListener("fullscreenchange", sync);
75
+ }
76
+ };
77
+ };
78
+ }
79
+ //#endregion
80
+ //#region src/components/mute/mute.ts
81
+ /**
82
+ * A mute toggle for background audio. daisyUI prefab (styled with `btn`). Drives
83
+ * `ctx.media` master mute, so it silences every source while keeping their
84
+ * individual volumes — one click restores the mix exactly. Defaults to plain
85
+ * glyphs (no icon library); pass `icons` (e.g. `lucideIcon`) for richer icons.
86
+ */
87
+ function muteButton(options = {}) {
88
+ const on = options.icons?.on ?? "🔊";
89
+ const off = options.icons?.off ?? "🔇";
90
+ return (ctx) => {
91
+ const media = ctx.gallery.ctx.media;
92
+ const button = document.createElement("button");
93
+ button.type = "button";
94
+ button.className = options.className ?? "btn btn-circle btn-sm";
95
+ const sync = () => {
96
+ const muted = media.muted();
97
+ setIcon(button, muted ? off : on);
98
+ button.setAttribute("aria-label", muted ? "Unmute" : "Mute");
99
+ };
100
+ const onClick = () => {
101
+ media.setMuted(!media.muted());
102
+ sync();
103
+ };
104
+ button.addEventListener("click", onClick);
105
+ sync();
106
+ return {
107
+ element: button,
108
+ destroy: () => button.removeEventListener("click", onClick)
109
+ };
110
+ };
111
+ }
112
+ //#endregion
113
+ //#region src/components/cardboard/cardboard.ts
114
+ /**
115
+ * A cardboard (stereoscopic) toggle. Flips `ctx.gallery.setRender({ cardboard })`
116
+ * to split the view into a left/right eye pair for a phone-in-a-headset viewer,
117
+ * and reflects the on state with daisyUI's `btn-active`. Defaults to a plain
118
+ * glyph; pass `icon` (e.g. `lucideIcon("RectangleGoggles")`).
119
+ */
120
+ function cardboardButton(options = {}) {
121
+ const icon = options.icon ?? "🥽";
122
+ const label = options.label ?? "Cardboard mode";
123
+ return (ctx) => {
124
+ const button = document.createElement("button");
125
+ button.type = "button";
126
+ button.className = options.className ?? "btn btn-circle btn-sm";
127
+ button.setAttribute("aria-label", label);
128
+ setIcon(button, icon);
129
+ let enabled = false;
130
+ const onClick = () => {
131
+ enabled = !enabled;
132
+ ctx.gallery.setRender({ cardboard: enabled });
133
+ button.classList.toggle("btn-active", enabled);
134
+ button.setAttribute("aria-pressed", String(enabled));
135
+ };
136
+ button.addEventListener("click", onClick);
137
+ return {
138
+ element: button,
139
+ destroy: () => button.removeEventListener("click", onClick)
140
+ };
141
+ };
142
+ }
143
+ //#endregion
144
+ //#region src/components/environment/environment.ts
145
+ /**
146
+ * An environment panel: a button (e.g. a sun/cloud icon) opening a daisyUI modal
147
+ * with sliders for ambient settings like time of day and sun azimuth. Optional
148
+ * in the default preset — galleries with a procedural sky opt in. daisyUI +
149
+ * Lucide prefab.
150
+ */
151
+ function environmentPanel(options) {
152
+ const icon = options.icon ?? "☀";
153
+ return (ctx) => {
154
+ const wrap = document.createElement("div");
155
+ const button = document.createElement("button");
156
+ button.type = "button";
157
+ button.className = "btn btn-circle btn-sm";
158
+ setIcon(button, icon);
159
+ const dialog = document.createElement("dialog");
160
+ dialog.className = "modal";
161
+ const box = document.createElement("div");
162
+ box.className = "modal-box max-w-sm";
163
+ const close = document.createElement("button");
164
+ close.type = "button";
165
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
166
+ close.textContent = "✕";
167
+ const heading = document.createElement("h3");
168
+ heading.className = "text-lg font-bold mb-4";
169
+ box.append(close, heading);
170
+ const cleanups = [];
171
+ const relabels = [];
172
+ const controls = /* @__PURE__ */ new Map();
173
+ for (const slider of options.sliders) {
174
+ const field = document.createElement("label");
175
+ field.className = "flex flex-col gap-1 mt-2 first:mt-0";
176
+ const head = document.createElement("span");
177
+ head.className = "label-text text-sm flex justify-between";
178
+ const name = document.createElement("span");
179
+ const value = document.createElement("span");
180
+ value.className = "opacity-60";
181
+ head.append(name, value);
182
+ const input = document.createElement("input");
183
+ input.type = "range";
184
+ input.className = "range range-sm";
185
+ input.min = String(slider.min);
186
+ input.max = String(slider.max);
187
+ input.step = String(slider.step ?? .1);
188
+ input.value = String(slider.value);
189
+ value.textContent = input.value;
190
+ const onInput = () => {
191
+ value.textContent = input.value;
192
+ slider.onChange(parseFloat(input.value));
193
+ };
194
+ input.addEventListener("input", onInput);
195
+ cleanups.push(() => input.removeEventListener("input", onInput));
196
+ field.append(head, input);
197
+ box.appendChild(field);
198
+ controls.set(slider.key, {
199
+ input,
200
+ value,
201
+ onChange: slider.onChange
202
+ });
203
+ relabels.push(() => {
204
+ name.textContent = resolveText(slider.label);
205
+ });
206
+ }
207
+ if (options.presets?.length) {
208
+ const row = document.createElement("div");
209
+ row.className = "flex flex-wrap gap-2 mt-4";
210
+ for (const preset of options.presets) {
211
+ const btn = document.createElement("button");
212
+ btn.type = "button";
213
+ btn.className = "btn btn-xs";
214
+ const apply = () => {
215
+ for (const [key, val] of Object.entries(preset.values)) {
216
+ const ctrl = controls.get(key);
217
+ if (!ctrl) continue;
218
+ ctrl.input.value = String(val);
219
+ ctrl.value.textContent = String(val);
220
+ ctrl.onChange(val);
221
+ }
222
+ };
223
+ btn.addEventListener("click", apply);
224
+ cleanups.push(() => btn.removeEventListener("click", apply));
225
+ row.appendChild(btn);
226
+ relabels.push(() => {
227
+ btn.textContent = resolveText(preset.label);
228
+ });
229
+ }
230
+ box.appendChild(row);
231
+ }
232
+ const backdrop = document.createElement("form");
233
+ backdrop.method = "dialog";
234
+ backdrop.className = "modal-backdrop";
235
+ const backdropBtn = document.createElement("button");
236
+ backdropBtn.textContent = "close";
237
+ backdrop.appendChild(backdropBtn);
238
+ dialog.append(box, backdrop);
239
+ wrap.append(button, dialog);
240
+ const unbind = bindLocale(ctx, () => {
241
+ const title = resolveText(options.title);
242
+ button.setAttribute("aria-label", title);
243
+ heading.textContent = title;
244
+ close.setAttribute("aria-label", resolveText(options.closeLabel ?? "Close"));
245
+ for (const relabel of relabels) relabel();
246
+ });
247
+ const onOpen = () => dialog.showModal();
248
+ const onClose = () => dialog.close();
249
+ button.addEventListener("click", onOpen);
250
+ close.addEventListener("click", onClose);
251
+ return {
252
+ element: wrap,
253
+ destroy: () => {
254
+ button.removeEventListener("click", onOpen);
255
+ close.removeEventListener("click", onClose);
256
+ for (const fn of cleanups) fn();
257
+ unbind();
258
+ }
259
+ };
260
+ };
261
+ }
262
+ //#endregion
263
+ //#region src/components/credits/credits.ts
264
+ let panelCount = 0;
265
+ const buildRow = (item) => {
266
+ const row = document.createElement("div");
267
+ row.className = "text-sm leading-snug";
268
+ const name = document.createElement(item.href ? "a" : "span");
269
+ name.textContent = item.label;
270
+ if (item.href) {
271
+ const link = name;
272
+ link.href = item.href;
273
+ link.target = "_blank";
274
+ link.rel = "noopener noreferrer";
275
+ link.className = "link link-hover font-medium";
276
+ } else name.className = "font-medium";
277
+ row.appendChild(name);
278
+ const meta = [item.author, item.license].filter(Boolean).join(" · ");
279
+ if (meta) {
280
+ const small = document.createElement("span");
281
+ small.className = "opacity-60";
282
+ small.textContent = ` — ${meta}`;
283
+ row.appendChild(small);
284
+ }
285
+ return row;
286
+ };
287
+ const buildSection = (section) => {
288
+ const heading = document.createElement("div");
289
+ heading.className = "font-semibold text-xs uppercase tracking-wide opacity-50 mt-3 first:mt-0";
290
+ heading.textContent = section.label;
291
+ return heading;
292
+ };
293
+ const buildEntries = (entries) => entries.map((entry) => entry.type === "section" ? buildSection(entry) : buildRow(entry));
294
+ /**
295
+ * A credits panel: a button opening a centered daisyUI modal (✕ close,
296
+ * click-outside, Esc) listing attributions. Entries are data (`items`): loose
297
+ * items and sections show first; `tab` entries become a daisyUI tab strip below
298
+ * (first tab open), so an exhibition can credit its author up top and group
299
+ * assets/technology/etc. into tabs with sectioned lists. Defaults to a plain
300
+ * glyph icon; pass `icon` (e.g. `lucideIcon`).
301
+ */
302
+ function creditsPanel(options) {
303
+ const icon = options.icon ?? "ⓘ";
304
+ return (ctx) => {
305
+ const tabsName = `sg-credits-tabs-${panelCount++}`;
306
+ const wrap = document.createElement("div");
307
+ const button = document.createElement("button");
308
+ button.type = "button";
309
+ button.className = options.className ?? "btn btn-circle btn-sm";
310
+ setIcon(button, icon);
311
+ const dialog = document.createElement("dialog");
312
+ dialog.className = "modal";
313
+ const box = document.createElement("div");
314
+ box.className = "modal-box";
315
+ const close = document.createElement("button");
316
+ close.type = "button";
317
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
318
+ close.textContent = "✕";
319
+ close.setAttribute("aria-label", "Close");
320
+ const heading = document.createElement("h3");
321
+ heading.className = "text-lg font-bold mb-3";
322
+ const loose = document.createElement("div");
323
+ loose.className = "flex flex-col gap-1";
324
+ const tablist = document.createElement("div");
325
+ tablist.setAttribute("role", "tablist");
326
+ tablist.className = "tabs tabs-border mt-2";
327
+ box.append(close, heading, loose, tablist);
328
+ const backdrop = document.createElement("form");
329
+ backdrop.method = "dialog";
330
+ backdrop.className = "modal-backdrop";
331
+ const backdropBtn = document.createElement("button");
332
+ backdropBtn.textContent = "close";
333
+ backdrop.appendChild(backdropBtn);
334
+ dialog.append(box, backdrop);
335
+ wrap.append(button, dialog);
336
+ const render = (entries) => {
337
+ const flat = entries.filter((e) => e.type !== "tab");
338
+ const tabs = entries.filter((e) => e.type === "tab");
339
+ loose.replaceChildren(...buildEntries(flat));
340
+ loose.style.display = flat.length ? "" : "none";
341
+ const nodes = [];
342
+ tabs.forEach((tab, i) => {
343
+ const input = document.createElement("input");
344
+ input.type = "radio";
345
+ input.name = tabsName;
346
+ input.role = "tab";
347
+ input.className = "tab";
348
+ input.setAttribute("aria-label", tab.label);
349
+ if (i === 0) input.checked = true;
350
+ const pane = document.createElement("div");
351
+ pane.className = "tab-content pt-3";
352
+ const list = document.createElement("div");
353
+ list.className = "flex flex-col gap-1";
354
+ list.replaceChildren(...buildEntries(tab.children));
355
+ pane.appendChild(list);
356
+ nodes.push(input, pane);
357
+ });
358
+ tablist.replaceChildren(...nodes);
359
+ tablist.style.display = tabs.length ? "" : "none";
360
+ };
361
+ const unbind = bindLocale(ctx, () => {
362
+ const title = resolveText(options.title ?? "Credits");
363
+ button.setAttribute("aria-label", title);
364
+ heading.textContent = title;
365
+ render(typeof options.items === "function" ? options.items() : options.items);
366
+ });
367
+ const onOpen = () => dialog.showModal();
368
+ const onClose = () => dialog.close();
369
+ button.addEventListener("click", onOpen);
370
+ close.addEventListener("click", onClose);
371
+ return {
372
+ element: wrap,
373
+ destroy() {
374
+ button.removeEventListener("click", onOpen);
375
+ close.removeEventListener("click", onClose);
376
+ unbind();
377
+ }
378
+ };
379
+ };
380
+ }
381
+ //#endregion
382
+ //#region src/components/action-button/action-button.ts
383
+ /**
384
+ * A generic clickable button — the lucide-free alternative to the opinionated
385
+ * prefabs. Supply any `icon` (glyph, your own SVG, a Lucide icon you wired up)
386
+ * and an `onClick`. daisyUI prefab (styled with `btn`).
387
+ */
388
+ function actionButton(options) {
389
+ return (ctx) => {
390
+ const button = document.createElement("button");
391
+ button.type = "button";
392
+ button.className = options.className ?? "btn btn-sm";
393
+ if (options.icon !== void 0) setIcon(button, options.icon);
394
+ if (options.label !== void 0) {
395
+ const label = document.createElement("span");
396
+ label.textContent = options.label;
397
+ button.appendChild(label);
398
+ }
399
+ if (options.ariaLabel) button.setAttribute("aria-label", options.ariaLabel);
400
+ const onClick = () => options.onClick(ctx);
401
+ button.addEventListener("click", onClick);
402
+ return {
403
+ element: button,
404
+ destroy: () => button.removeEventListener("click", onClick)
405
+ };
406
+ };
407
+ }
408
+ //#endregion
409
+ //#region src/components/info/info.ts
410
+ /**
411
+ * An info panel: an ⓘ button that opens a centered daisyUI modal with a title
412
+ * and a block of text (with an ✕ close, click-outside and Esc). Content is data,
413
+ * so the app supplies the (already localized) strings. daisyUI + Lucide prefab.
414
+ */
415
+ function infoPanel(options) {
416
+ return (ctx) => {
417
+ const wrap = document.createElement("div");
418
+ const button = document.createElement("button");
419
+ button.type = "button";
420
+ button.className = "btn btn-circle btn-sm";
421
+ setIcon(button, lucideIcon("Info"));
422
+ const dialog = document.createElement("dialog");
423
+ dialog.className = "modal";
424
+ const box = document.createElement("div");
425
+ box.className = "modal-box max-w-sm";
426
+ const close = document.createElement("button");
427
+ close.type = "button";
428
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
429
+ close.textContent = "✕";
430
+ const heading = document.createElement("h3");
431
+ heading.className = "text-lg font-bold mb-3";
432
+ const text = document.createElement("p");
433
+ text.className = "text-sm leading-relaxed";
434
+ box.append(close, heading, text);
435
+ const backdrop = document.createElement("form");
436
+ backdrop.method = "dialog";
437
+ backdrop.className = "modal-backdrop";
438
+ const backdropBtn = document.createElement("button");
439
+ backdropBtn.textContent = "close";
440
+ backdrop.appendChild(backdropBtn);
441
+ dialog.append(box, backdrop);
442
+ wrap.append(button, dialog);
443
+ const unbind = bindLocale(ctx, () => {
444
+ const title = resolveText(options.title);
445
+ button.setAttribute("aria-label", title);
446
+ heading.textContent = title;
447
+ text.textContent = resolveText(options.text);
448
+ close.setAttribute("aria-label", resolveText(options.closeLabel ?? "Close"));
449
+ });
450
+ const onOpen = () => dialog.showModal();
451
+ const onClose = () => dialog.close();
452
+ button.addEventListener("click", onOpen);
453
+ close.addEventListener("click", onClose);
454
+ return {
455
+ element: wrap,
456
+ destroy: () => {
457
+ button.removeEventListener("click", onOpen);
458
+ close.removeEventListener("click", onClose);
459
+ unbind();
460
+ }
461
+ };
462
+ };
463
+ }
464
+ //#endregion
465
+ //#region src/components/artwork-info/artwork-info.ts
466
+ const AVAILABILITY_COLOR = {
467
+ available: "#22c55e",
468
+ unavailable: "#ef4444"
469
+ };
470
+ const STYLE_ID = "scenography-artwork-info-styles";
471
+ const PANEL_FRACTION = .4;
472
+ const FREE_FRACTION = 1 - PANEL_FRACTION;
473
+ const CENTER_X = .7;
474
+ /**
475
+ * Inject the panel's own CSS once. Self-contained on purpose: a consumer's
476
+ * Tailwind build doesn't scan this package in `node_modules`, so utility classes
477
+ * wouldn't be generated — the panel must not depend on them.
478
+ */
479
+ function ensureStyles() {
480
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
481
+ const style = document.createElement("style");
482
+ style.id = STYLE_ID;
483
+ style.textContent = `.sg-artwork-panel{position:absolute;left:0;top:0;height:100%;width:${PANEL_FRACTION * 100}%;box-sizing:border-box;background:rgba(0,0,0,0.85);color:#fff;padding:2.5rem;z-index:10;overflow-y:auto;display:none;flex-direction:column;gap:0.75rem;pointer-events:auto;font-family:system-ui,sans-serif;}.sg-artwork-panel--open{display:flex;}.sg-artwork-panel__close{align-self:flex-end;background:none;border:none;color:#fff;font-size:1.5rem;line-height:1;cursor:pointer;opacity:0.7;padding:0;}.sg-artwork-panel__close:hover{opacity:1;}.sg-artwork-panel__title{font-size:1.75rem;font-weight:700;margin:0;}.sg-artwork-panel__meta{display:flex;flex-direction:column;gap:0.15rem;font-size:0.9rem;opacity:0.8;}.sg-artwork-panel__status{display:flex;align-items:center;gap:0.5rem;font-size:0.9rem;font-weight:600;}.sg-artwork-panel__dot{width:0.7rem;height:0.7rem;border-radius:50%;flex:none;}.sg-artwork-panel__desc{font-size:1rem;line-height:1.6;margin-top:0.5rem;white-space:pre-line;}.sg-artwork-panel__link{align-self:flex-start;margin-top:0.5rem;display:inline-flex;align-items:center;gap:0.4rem;color:#fff;text-decoration:underline;font-size:0.95rem;}.sg-artwork-panel__link:hover{opacity:0.8;}`;
484
+ document.head.appendChild(style);
485
+ }
486
+ const join = (parts, sep) => parts.filter((p) => Boolean(p)).join(sep);
487
+ /**
488
+ * The artwork info panel. The engine emits `artwork:select { id }` when you click
489
+ * a piece's cartela (or the piece itself); this opens a left-hand panel (40%
490
+ * width, full height, black at 85% alpha) with the full caption and asks the
491
+ * camera to frame the piece in the free right 60%. Closing with ✕ re-centers the
492
+ * piece full-width; if the user walks/turns away the engine emits
493
+ * `artwork:released` and the panel just closes (no camera move).
494
+ *
495
+ * Content is data (`artworks`), unlocalized for now. The panel is mounted on the
496
+ * surface (escaping the overlay grid); the slot element is an inert placeholder.
497
+ */
498
+ function artworkInfo(options) {
499
+ const byId = new Map(options.artworks.map((a) => [a.id, a]));
500
+ return (ctx) => {
501
+ ensureStyles();
502
+ const camera = ctx.gallery.ctx.camera;
503
+ const panel = document.createElement("div");
504
+ panel.className = "sg-artwork-panel sg-overlay__item";
505
+ const close = document.createElement("button");
506
+ close.type = "button";
507
+ close.className = "sg-artwork-panel__close";
508
+ close.textContent = "✕";
509
+ close.setAttribute("aria-label", options.closeLabel ?? "Close");
510
+ const title = document.createElement("h2");
511
+ title.className = "sg-artwork-panel__title";
512
+ const meta = document.createElement("div");
513
+ meta.className = "sg-artwork-panel__meta";
514
+ const status = document.createElement("div");
515
+ status.className = "sg-artwork-panel__status";
516
+ const statusDot = document.createElement("span");
517
+ statusDot.className = "sg-artwork-panel__dot";
518
+ const statusText = document.createElement("span");
519
+ status.append(statusDot, statusText);
520
+ const desc = document.createElement("p");
521
+ desc.className = "sg-artwork-panel__desc";
522
+ const link = document.createElement("a");
523
+ link.className = "sg-artwork-panel__link";
524
+ link.target = "_blank";
525
+ link.rel = "noopener noreferrer";
526
+ link.textContent = `${options.linkText ?? "View artwork"} ↗`;
527
+ panel.append(close, title, meta, status, desc, link);
528
+ ctx.surface.appendChild(panel);
529
+ let currentId = null;
530
+ const render = (artwork) => {
531
+ title.textContent = artwork.title ?? "";
532
+ const lines = [join([artwork.author, artwork.year?.toString()], ", "), join([artwork.technique, artwork.dimensions], ", ")].filter(Boolean);
533
+ meta.replaceChildren(...lines.map((line) => {
534
+ const span = document.createElement("span");
535
+ span.textContent = line;
536
+ return span;
537
+ }));
538
+ const av = artwork.availability;
539
+ if (av === void 0) status.style.display = "none";
540
+ else {
541
+ status.style.display = "flex";
542
+ const forSale = av === "for-sale";
543
+ statusDot.style.backgroundColor = forSale ? AVAILABILITY_COLOR.available : AVAILABILITY_COLOR.unavailable;
544
+ statusText.textContent = forSale ? options.availabilityText?.forSale ?? "For sale" : options.availabilityText?.privateCollection ?? "Private collection";
545
+ }
546
+ desc.textContent = artwork.description ?? "";
547
+ if (artwork.href) {
548
+ link.href = artwork.href;
549
+ link.style.display = "inline-flex";
550
+ } else link.style.display = "none";
551
+ };
552
+ const hide = () => {
553
+ panel.classList.remove("sg-artwork-panel--open");
554
+ currentId = null;
555
+ };
556
+ const onSelect = (payload) => {
557
+ const id = payload?.id;
558
+ const artwork = id ? byId.get(id) : void 0;
559
+ if (!artwork || !id) return;
560
+ currentId = id;
561
+ render(artwork);
562
+ panel.classList.add("sg-artwork-panel--open");
563
+ camera.focusArtwork(id, {
564
+ centerX: CENTER_X,
565
+ widthFraction: FREE_FRACTION
566
+ });
567
+ };
568
+ const onReleased = (payload) => {
569
+ const id = payload?.id;
570
+ if (id && id === currentId) hide();
571
+ };
572
+ const onClose = () => {
573
+ const id = currentId;
574
+ hide();
575
+ if (id) camera.centerArtwork(id);
576
+ };
577
+ const offSelect = ctx.gallery.on("artwork:select", onSelect);
578
+ const offReleased = ctx.gallery.on("artwork:released", onReleased);
579
+ close.addEventListener("click", onClose);
580
+ const placeholder = document.createElement("div");
581
+ placeholder.style.display = "none";
582
+ return {
583
+ element: placeholder,
584
+ destroy() {
585
+ offSelect();
586
+ offReleased();
587
+ close.removeEventListener("click", onClose);
588
+ panel.remove();
589
+ }
590
+ };
591
+ };
592
+ }
593
+ //#endregion
594
+ //#region src/components/watermark/watermark.ts
595
+ /**
596
+ * A small, unobtrusive text watermark (a "brand stamp"). No daisyUI/Lucide — just
597
+ * a styled element. Defaults to the text "scenography".
598
+ */
599
+ function watermark(options = {}) {
600
+ return () => {
601
+ const el = document.createElement("div");
602
+ el.textContent = options.text ?? "scenography";
603
+ if (options.className) el.className = options.className;
604
+ else el.style.cssText = "font:600 13px system-ui,sans-serif;color:#fff;opacity:0.6;";
605
+ return el;
606
+ };
607
+ }
608
+ //#endregion
609
+ //#region src/components/settings/settings.ts
610
+ /**
611
+ * A settings panel: a ⚙ button that opens a centered daisyUI modal (with an ✕
612
+ * close, click-outside and Esc). Each section lives in its own tab (the first
613
+ * present one opens by default). Sections are data — the app supplies labels,
614
+ * current values and callbacks — so the panel stays generic. Labels are `Text`
615
+ * and relocalize live via the overlay's locale source. daisyUI + Lucide prefab.
616
+ */
617
+ function settingsPanel(options) {
618
+ return (ctx) => {
619
+ const wrap = document.createElement("div");
620
+ const button = document.createElement("button");
621
+ button.type = "button";
622
+ button.className = "btn btn-circle btn-sm";
623
+ setIcon(button, lucideIcon("Settings"));
624
+ const dialog = document.createElement("dialog");
625
+ dialog.className = "modal";
626
+ const box = document.createElement("div");
627
+ box.className = "modal-box max-w-sm";
628
+ const close = document.createElement("button");
629
+ close.type = "button";
630
+ close.className = "btn btn-sm btn-circle btn-ghost absolute right-2 top-2";
631
+ close.textContent = "✕";
632
+ box.appendChild(close);
633
+ const heading = document.createElement("h3");
634
+ heading.className = "text-lg font-bold mb-4";
635
+ box.appendChild(heading);
636
+ const tablist = document.createElement("div");
637
+ tablist.setAttribute("role", "tablist");
638
+ tablist.className = "tabs tabs-border";
639
+ box.appendChild(tablist);
640
+ const cleanups = [];
641
+ const relabels = [];
642
+ let first = true;
643
+ const addTab = (tab, fallback, content) => {
644
+ const input = document.createElement("input");
645
+ input.type = "radio";
646
+ input.name = "sg-settings-tabs";
647
+ input.role = "tab";
648
+ input.className = "tab";
649
+ if (first) {
650
+ input.checked = true;
651
+ first = false;
652
+ }
653
+ const pane = document.createElement("div");
654
+ pane.className = "tab-content pt-4";
655
+ pane.appendChild(content);
656
+ tablist.append(input, pane);
657
+ relabels.push(() => input.setAttribute("aria-label", resolveText(tab ?? fallback)));
658
+ };
659
+ if (options.language) {
660
+ const lang = options.language;
661
+ const field = document.createElement("label");
662
+ field.className = "form-control w-full";
663
+ const label = document.createElement("span");
664
+ label.className = "label-text mb-1 block";
665
+ const select = document.createElement("select");
666
+ select.className = "select select-bordered select-sm w-full";
667
+ const optionEls = lang.options.map((opt) => {
668
+ const o = document.createElement("option");
669
+ o.value = opt.value;
670
+ select.appendChild(o);
671
+ return {
672
+ o,
673
+ label: opt.label
674
+ };
675
+ });
676
+ select.value = lang.value;
677
+ const onChange = () => lang.onChange(select.value);
678
+ select.addEventListener("change", onChange);
679
+ cleanups.push(() => select.removeEventListener("change", onChange));
680
+ relabels.push(() => {
681
+ label.textContent = resolveText(lang.label);
682
+ for (const { o, label: l } of optionEls) o.textContent = resolveText(l);
683
+ });
684
+ field.append(label, select);
685
+ addTab(lang.tab, lang.label, field);
686
+ }
687
+ if (options.handedness) {
688
+ const hand = options.handedness;
689
+ const pane = document.createElement("div");
690
+ const legend = document.createElement("div");
691
+ legend.className = "label-text mb-1 block";
692
+ pane.appendChild(legend);
693
+ const onChange = (e) => hand.onChange(e.target.value);
694
+ const texts = [];
695
+ for (const opt of hand.options) {
696
+ const row = document.createElement("label");
697
+ row.className = "label cursor-pointer justify-start gap-3 py-1";
698
+ const input = document.createElement("input");
699
+ input.type = "radio";
700
+ input.name = "sg-settings-handedness";
701
+ input.value = opt.value;
702
+ input.className = "radio radio-sm";
703
+ input.checked = opt.value === hand.value;
704
+ input.addEventListener("change", onChange);
705
+ cleanups.push(() => input.removeEventListener("change", onChange));
706
+ const text = document.createElement("span");
707
+ text.className = "label-text text-sm";
708
+ row.append(input, text);
709
+ pane.appendChild(row);
710
+ texts.push({
711
+ el: text,
712
+ label: opt.label
713
+ });
714
+ }
715
+ relabels.push(() => {
716
+ legend.textContent = resolveText(hand.label);
717
+ for (const { el, label } of texts) el.textContent = resolveText(label);
718
+ });
719
+ addTab(hand.tab, hand.label, pane);
720
+ }
721
+ if (options.graphics) {
722
+ const gfx = options.graphics;
723
+ const pane = document.createElement("div");
724
+ const legend = document.createElement("div");
725
+ legend.className = "label-text mb-1 block";
726
+ pane.appendChild(legend);
727
+ const groupLabels = [];
728
+ for (const group of gfx.choiceGroups ?? []) {
729
+ const field = document.createElement("div");
730
+ field.className = "mb-3";
731
+ const label = document.createElement("span");
732
+ label.className = "label-text text-sm mb-1 block";
733
+ const row = document.createElement("div");
734
+ row.className = "join w-full";
735
+ const name = `sg-settings-${group.key}`;
736
+ const opts = group.options.map((opt) => {
737
+ const input = document.createElement("input");
738
+ input.type = "radio";
739
+ input.name = name;
740
+ input.className = "btn btn-sm join-item flex-1";
741
+ input.value = opt.value;
742
+ input.checked = opt.value === group.value;
743
+ const onChange = () => group.onChange(input.value);
744
+ input.addEventListener("change", onChange);
745
+ cleanups.push(() => input.removeEventListener("change", onChange));
746
+ row.appendChild(input);
747
+ return {
748
+ input,
749
+ label: opt.label
750
+ };
751
+ });
752
+ field.append(label, row);
753
+ pane.appendChild(field);
754
+ groupLabels.push({
755
+ el: label,
756
+ label: group.label,
757
+ opts
758
+ });
759
+ }
760
+ const list = document.createElement("div");
761
+ list.className = "flex flex-col gap-1";
762
+ pane.appendChild(list);
763
+ const texts = [];
764
+ for (const item of gfx.items) {
765
+ const row = document.createElement("label");
766
+ row.className = "label cursor-pointer justify-start gap-3 py-1";
767
+ const input = document.createElement("input");
768
+ input.type = "checkbox";
769
+ input.className = "toggle toggle-sm";
770
+ input.checked = item.value;
771
+ const onChange = () => gfx.onChange(item.key, input.checked);
772
+ input.addEventListener("change", onChange);
773
+ cleanups.push(() => input.removeEventListener("change", onChange));
774
+ const text = document.createElement("span");
775
+ text.className = "label-text text-sm";
776
+ row.append(input, text);
777
+ list.appendChild(row);
778
+ texts.push({
779
+ el: text,
780
+ label: item.label
781
+ });
782
+ }
783
+ const sliderLabels = [];
784
+ for (const slider of gfx.sliders ?? []) {
785
+ const field = document.createElement("label");
786
+ field.className = "flex flex-col gap-1 mt-2";
787
+ const head = document.createElement("span");
788
+ head.className = "label-text text-sm flex justify-between";
789
+ const name = document.createElement("span");
790
+ const value = document.createElement("span");
791
+ value.className = "opacity-60";
792
+ head.append(name, value);
793
+ const input = document.createElement("input");
794
+ input.type = "range";
795
+ input.className = "range range-sm";
796
+ input.min = String(slider.min);
797
+ input.max = String(slider.max);
798
+ input.step = String(slider.step ?? .1);
799
+ input.value = String(slider.value);
800
+ value.textContent = input.value;
801
+ const onInput = () => {
802
+ value.textContent = input.value;
803
+ slider.onChange(parseFloat(input.value));
804
+ };
805
+ input.addEventListener("input", onInput);
806
+ cleanups.push(() => input.removeEventListener("input", onInput));
807
+ field.append(head, input);
808
+ pane.appendChild(field);
809
+ sliderLabels.push({
810
+ el: name,
811
+ label: slider.label
812
+ });
813
+ }
814
+ relabels.push(() => {
815
+ legend.textContent = resolveText(gfx.label);
816
+ for (const { el, label, opts } of groupLabels) {
817
+ el.textContent = resolveText(label);
818
+ for (const { input, label: l } of opts) input.setAttribute("aria-label", resolveText(l));
819
+ }
820
+ for (const { el, label } of texts) el.textContent = resolveText(label);
821
+ for (const { el, label } of sliderLabels) el.textContent = resolveText(label);
822
+ });
823
+ addTab(gfx.tab, gfx.label, pane);
824
+ }
825
+ const backdrop = document.createElement("form");
826
+ backdrop.method = "dialog";
827
+ backdrop.className = "modal-backdrop";
828
+ const backdropBtn = document.createElement("button");
829
+ backdropBtn.textContent = "close";
830
+ backdrop.appendChild(backdropBtn);
831
+ dialog.append(box, backdrop);
832
+ wrap.append(button, dialog);
833
+ const unbind = bindLocale(ctx, () => {
834
+ const title = resolveText(options.title);
835
+ button.setAttribute("aria-label", title);
836
+ heading.textContent = title;
837
+ close.setAttribute("aria-label", resolveText(options.closeLabel ?? "Close"));
838
+ for (const relabel of relabels) relabel();
839
+ });
840
+ const onOpen = () => dialog.showModal();
841
+ const onClose = () => dialog.close();
842
+ button.addEventListener("click", onOpen);
843
+ close.addEventListener("click", onClose);
844
+ return {
845
+ element: wrap,
846
+ destroy: () => {
847
+ button.removeEventListener("click", onOpen);
848
+ close.removeEventListener("click", onClose);
849
+ for (const fn of cleanups) fn();
850
+ unbind();
851
+ }
852
+ };
853
+ };
854
+ }
855
+ //#endregion
856
+ //#region src/presets/default.ts
857
+ /**
858
+ * The default overlay layout: mute + fullscreen (and a settings ⚙ when given)
859
+ * top-right; an info ⓘ and a credits panel bottom-right when given. Wired with
860
+ * Lucide icons — this is the opinionated, batteries-included layer, so the
861
+ * coupling to daisyUI + Lucide lives here, not in `@scenography/overlay`.
862
+ */
863
+ function defaultPreset(overlay, options = {}) {
864
+ overlay.add("top-right", muteButton({ icons: {
865
+ on: lucideIcon("Volume2"),
866
+ off: lucideIcon("VolumeX")
867
+ } }));
868
+ overlay.add("top-right", fullscreenButton({ icons: {
869
+ enter: lucideIcon("Maximize"),
870
+ exit: lucideIcon("Minimize")
871
+ } }));
872
+ if (options.settings) overlay.add("top-right", settingsPanel(options.settings));
873
+ if (options.environment) overlay.add("top-right", environmentPanel({
874
+ icon: lucideIcon("CloudSun"),
875
+ ...options.environment
876
+ }));
877
+ overlay.add("top-right", cardboardButton({ icon: lucideIcon("RectangleGoggles") }));
878
+ if (options.artworks) overlay.add("middle-left", artworkInfo(options.artworks));
879
+ if (options.info) overlay.add("bottom-right", infoPanel(options.info));
880
+ if (options.credits) overlay.add("bottom-right", creditsPanel({
881
+ items: options.credits,
882
+ title: options.creditsTitle,
883
+ icon: lucideIcon("Copyleft")
884
+ }));
885
+ if (options.watermark !== false) overlay.add("bottom-left", watermark(options.watermark ?? {}));
886
+ }
887
+ //#endregion
888
+ export { actionButton, artworkInfo, cardboardButton, creditsPanel, defaultPreset, environmentPanel, fullscreenButton, infoPanel, lucideIcon, muteButton, settingsPanel, watermark };
889
+
890
+ //# sourceMappingURL=index.mjs.map