@livx.cc/bare-v3 0.1.0-alpha.34 → 0.1.0-alpha.35

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/README.md CHANGED
@@ -17,7 +17,7 @@ The comparison UI needs the fixture server for example links. Model outputs repl
17
17
 
18
18
  Install the published alpha with `bun add @livx.cc/bare-v3@alpha`; use `bun pm pack` for an unpublished local build. Import CSS from `@livx.cc/bare-v3/css` or `/css/min`. Put `class="b3-root"` on html and set `data-b3-theme="dark"` explicitly for dark mode. Component references are exported under `/references/*.md`; start with `/references/start.md`. Place generic link defaults in `@layer bare.reset` so they do not override button/navigation colors. Unlayered consumer CSS overrides library layers: scope custom palette colors to each theme rather than applying light-only colors unconditionally to `:root`.
19
19
 
20
- `bun lab plan config.json` creates a seeded, capped development job plan. `bun lab prepare run.json` prepares one isolated workspace and manifest without invoking a model. The [visual playground](http://127.0.0.1:4313/fixtures/playground.html) demonstrates the current component contracts and shows the bare-v2 coverage audit. Composed apps include Signal (system performance), Elsewhere (local discovery/booking), Daylight (iOS/Android web studies), Relay, Gather setup and sign-in, Counter, Margin, Still, Fieldnotes, Interval, and the Forma project workspace. New components are an implemented subset, not full bare-v2 parity or a qualified release. Optional interactions are exported from `@livx.cc/bare-v3/ui`; call `mount(document)` once and use its returned cleanup on teardown.
20
+ `bun lab plan config.json` creates a seeded, capped development job plan. `bun lab prepare run.json` prepares one isolated workspace and manifest without invoking a model. The [visual playground](http://127.0.0.1:4313/fixtures/playground.html) demonstrates the current component contracts and shows the bare-v2 coverage audit. Composed apps include Signal (system performance), Elsewhere (local discovery/booking), Daylight (iOS/Android web studies), Relay, Gather setup and sign-in, Counter, Margin, Still, Fieldnotes, Interval, and the Forma project workspace. New components are an implemented subset, not full bare-v2 parity or a qualified release. Optional interactions are exported from `@livx.cc/bare-v3/ui`; call `mount(document)` once and use its returned cleanup on teardown. Genuinely hard interactive widgets ship separately as light-DOM custom elements — currently `@livx.cc/bare-v3/elements/image-viewer` (`<b3-image-viewer>`); see `recipes/image-viewer.md` and the plain/React/Vue demos in `fixtures/elements/`. `bun scripts/verify-image-viewer.ts` drives those demos in real headless Chrome and asserts measured transforms.
21
21
 
22
22
  Playground previews accept shareable app/width links, for example [Signal at 390px](http://127.0.0.1:4313/fixtures/playground.html?demo=metrics&width=390#demos). The controls keep these query parameters in sync; unknown values fall back to the authored defaults. This is an iframe layout preview, not mobile-device qualification.
23
23
 
@@ -0,0 +1,516 @@
1
+ // src/elements/image-viewer.js
2
+ var FIT = 1.001;
3
+ var DOUBLE_TAP_MS = 320;
4
+ var DOUBLE_TAP_SLOP = 30;
5
+ var CLICK_SWALLOW_MS = 350;
6
+ var STYLE_ID = "b3-image-viewer-style";
7
+ var STYLE = `
8
+ .b3-image-viewer{position:relative;display:block;overflow:hidden;touch-action:pan-y;
9
+ background:var(--b3-color-canvas,#f6f5f0);color:var(--b3-color-text,#202923);
10
+ border-radius:var(--b3-radius,.75rem);-webkit-user-select:none;user-select:none;}
11
+ .b3-image-viewer:focus-visible{outline:2px solid var(--b3-color-focus,#276bdd);outline-offset:2px;}
12
+ .b3-image-viewer>img{display:block;max-width:100%;max-height:100%;margin:0 auto;
13
+ will-change:transform;-webkit-user-drag:none;}
14
+ .b3-image-viewer[open]{position:fixed;inset:0;z-index:100;display:flex;align-items:center;
15
+ justify-content:center;border-radius:0;background:var(--b3-color-canvas,#f6f5f0);}
16
+ .b3-image-viewer[open]>img{max-width:100vw;max-height:100vh;}
17
+ .b3-image-viewer[data-b3-pannable]{cursor:grab;}
18
+ .b3-image-viewer[data-b3-zoomed]{cursor:grabbing;}
19
+ .b3-image-viewer>.b3-image-viewer-close,.b3-image-viewer>.b3-image-viewer-fit{position:absolute;
20
+ z-index:1;top:var(--b3-space,1rem);border:1px solid var(--b3-color-border,#d5dbd3);
21
+ border-radius:var(--b3-radius,.75rem);background:var(--b3-color-surface,#fff);
22
+ color:var(--b3-color-text,#202923);font:inherit;line-height:1;padding:.5rem .75rem;cursor:pointer;
23
+ min-height:44px;min-width:44px;}
24
+ .b3-image-viewer>.b3-image-viewer-close{inset-inline-end:var(--b3-space,1rem);}
25
+ .b3-image-viewer>.b3-image-viewer-fit{inset-inline-start:var(--b3-space,1rem);}
26
+ `;
27
+ function ensureStyle(document) {
28
+ if (document.getElementById(STYLE_ID))
29
+ return;
30
+ const style = document.createElement("style");
31
+ style.id = STYLE_ID;
32
+ style.textContent = STYLE;
33
+ document.head.append(style);
34
+ }
35
+
36
+ class B3ImageViewer extends HTMLElement {
37
+ static observedAttributes = ["src", "alt", "max-scale", "open"];
38
+ #scale = 1;
39
+ #tx = 0;
40
+ #ty = 0;
41
+ #overflows = false;
42
+ #base = null;
43
+ #pointers = new Map;
44
+ #didGesture = false;
45
+ #gestureEndedAt = 0;
46
+ #lastTap = 0;
47
+ #lastTapX = 0;
48
+ #lastTapY = 0;
49
+ #upgraded = false;
50
+ #controller = null;
51
+ #observer = null;
52
+ #wired = new WeakSet;
53
+ #source = null;
54
+ get scale() {
55
+ return this.#scale;
56
+ }
57
+ get fitted() {
58
+ return this.#scale <= FIT;
59
+ }
60
+ get src() {
61
+ return this.#source ?? this.getAttribute("src");
62
+ }
63
+ set src(value) {
64
+ if (value && typeof value === "object") {
65
+ this.#source = value.src ?? value.default ?? String(value);
66
+ } else {
67
+ this.#source = null;
68
+ if (value == null)
69
+ this.removeAttribute("src");
70
+ else
71
+ this.setAttribute("src", value);
72
+ }
73
+ this.#syncImage();
74
+ }
75
+ get alt() {
76
+ return this.getAttribute("alt") ?? "";
77
+ }
78
+ set alt(value) {
79
+ this.setAttribute("alt", value ?? "");
80
+ }
81
+ get maxScale() {
82
+ const value = Number(this.getAttribute("max-scale"));
83
+ return value >= 1 ? value : 8;
84
+ }
85
+ set maxScale(value) {
86
+ this.setAttribute("max-scale", String(value));
87
+ }
88
+ get open() {
89
+ return this.hasAttribute("open");
90
+ }
91
+ set open(value) {
92
+ this.toggleAttribute("open", Boolean(value));
93
+ }
94
+ connectedCallback() {
95
+ if (this.#upgraded)
96
+ return;
97
+ this.#upgraded = true;
98
+ ensureStyle(this.ownerDocument);
99
+ this.classList.add("b3-image-viewer");
100
+ if (!this.hasAttribute("tabindex"))
101
+ this.tabIndex = 0;
102
+ if (!this.hasAttribute("role"))
103
+ this.setAttribute("role", "group");
104
+ this.#nameHost();
105
+ this.#syncImage();
106
+ this.#listen();
107
+ this.#observer = new MutationObserver(() => {
108
+ this.#ensureChrome();
109
+ this.#apply();
110
+ });
111
+ this.#observer.observe(this, { childList: true });
112
+ this.#ensureChrome();
113
+ this.#apply();
114
+ }
115
+ disconnectedCallback() {
116
+ this.#controller?.abort();
117
+ this.#controller = null;
118
+ this.#observer?.disconnect();
119
+ this.#observer = null;
120
+ for (const id of this.#pointers.keys())
121
+ if (this.hasPointerCapture?.(id))
122
+ this.releasePointerCapture(id);
123
+ this.#pointers.clear();
124
+ this.#base = null;
125
+ this.#didGesture = false;
126
+ this.#upgraded = false;
127
+ }
128
+ attributeChangedCallback(name, previous, next) {
129
+ if (previous === next)
130
+ return;
131
+ if (name === "src") {
132
+ this.#syncImage();
133
+ this.reset();
134
+ }
135
+ if (name === "alt") {
136
+ const image = this.#content();
137
+ if (image)
138
+ image.alt = this.alt;
139
+ this.#nameHost();
140
+ }
141
+ if (name === "open") {
142
+ this.#ensureChrome();
143
+ this.reset();
144
+ if (this.open)
145
+ this.focus({ preventScroll: true });
146
+ }
147
+ if (name === "max-scale" && this.#scale > this.maxScale)
148
+ this.zoomTo(this.maxScale);
149
+ }
150
+ #content() {
151
+ return this.querySelector(":scope > img, :scope > svg, :scope > canvas");
152
+ }
153
+ #syncImage() {
154
+ let image = this.#content();
155
+ const source = this.src;
156
+ let created = false;
157
+ if (!image && source) {
158
+ image = this.ownerDocument.createElement("img");
159
+ created = true;
160
+ this.prepend(image);
161
+ }
162
+ if (!image)
163
+ return;
164
+ if (source && image.tagName === "IMG" && image.getAttribute("src") !== source)
165
+ image.setAttribute("src", source);
166
+ if (image.tagName === "IMG" && (created || this.hasAttribute("alt")))
167
+ image.alt = this.alt;
168
+ this.#nameHost();
169
+ if (image.tagName === "IMG")
170
+ this.#watch(image);
171
+ }
172
+ #nameHost() {
173
+ if (this.hasAttribute("aria-labelledby"))
174
+ return;
175
+ const name = this.getAttribute("alt");
176
+ if (name)
177
+ this.setAttribute("aria-label", name);
178
+ else if (!this.hasAttribute("aria-label"))
179
+ this.setAttribute("aria-label", "Zoomable image");
180
+ }
181
+ #watch(image) {
182
+ if (this.#wired.has(image))
183
+ return;
184
+ this.#wired.add(image);
185
+ image.addEventListener("load", () => {
186
+ this.removeAttribute("data-b3-error");
187
+ this.reset();
188
+ });
189
+ image.addEventListener("error", () => this.#fail(image));
190
+ if (image.complete && image.naturalWidth === 0 && image.getAttribute("src"))
191
+ setTimeout(() => {
192
+ if (image.isConnected && image.complete && image.naturalWidth === 0)
193
+ this.#fail(image);
194
+ }, 0);
195
+ }
196
+ #fail(image) {
197
+ if (this.hasAttribute("data-b3-error"))
198
+ return;
199
+ this.setAttribute("data-b3-error", "");
200
+ this.dispatchEvent(new CustomEvent("b3-error", {
201
+ bubbles: true,
202
+ composed: true,
203
+ detail: { src: image.getAttribute("src") ?? this.src ?? null }
204
+ }));
205
+ }
206
+ #ensureChrome() {
207
+ if (!this.open) {
208
+ for (const node of this.querySelectorAll(":scope > .b3-image-viewer-close"))
209
+ node.remove();
210
+ return;
211
+ }
212
+ if (this.querySelector(":scope > .b3-image-viewer-close"))
213
+ return;
214
+ const close = this.ownerDocument.createElement("button");
215
+ close.type = "button";
216
+ close.className = "b3-image-viewer-close";
217
+ close.setAttribute("aria-label", "Close");
218
+ close.textContent = "×";
219
+ close.addEventListener("click", (event) => {
220
+ event.preventDefault();
221
+ event.stopPropagation();
222
+ this.requestClose();
223
+ });
224
+ close.addEventListener("pointerdown", (event) => event.stopPropagation());
225
+ this.append(close);
226
+ }
227
+ #measure() {
228
+ const element = this.#content();
229
+ if (!element)
230
+ return null;
231
+ const saved = element.style.transform;
232
+ element.style.transform = "none";
233
+ const viewport = this.getBoundingClientRect();
234
+ const content = element.getBoundingClientRect();
235
+ element.style.transform = saved;
236
+ this.#overflows = content.width > viewport.width + 1 || content.height > viewport.height + 1;
237
+ return {
238
+ element,
239
+ vw: viewport.width,
240
+ vh: viewport.height,
241
+ x: content.left - viewport.left,
242
+ y: content.top - viewport.top,
243
+ w: content.width,
244
+ h: content.height
245
+ };
246
+ }
247
+ #clamp(box) {
248
+ if (this.#scale <= FIT && !this.#overflows) {
249
+ this.#tx = 0;
250
+ this.#ty = 0;
251
+ return;
252
+ }
253
+ const w = box.w * this.#scale, h = box.h * this.#scale;
254
+ this.#tx = w <= box.vw ? (box.vw - w) / 2 - box.x : Math.min(-box.x, Math.max(-box.x - (w - box.vw), this.#tx));
255
+ this.#ty = h <= box.vh ? (box.vh - h) / 2 - box.y : Math.min(-box.y, Math.max(-box.y - (h - box.vh), this.#ty));
256
+ }
257
+ #apply(box) {
258
+ const element = box?.element ?? this.#content();
259
+ if (!element)
260
+ return;
261
+ const fitted = this.#scale <= FIT;
262
+ const pannable = !fitted || this.#overflows;
263
+ element.style.transformOrigin = "0 0";
264
+ element.style.transform = fitted && !this.#tx && !this.#ty ? "" : `translate(${this.#tx}px, ${this.#ty}px) scale(${this.#scale})`;
265
+ element.style.setProperty("--b3-zoom", this.#scale);
266
+ this.toggleAttribute("data-b3-zoomed", !fitted);
267
+ this.toggleAttribute("data-b3-pannable", pannable);
268
+ this.style.touchAction = pannable || this.open ? "none" : "pan-y";
269
+ const fit = this.querySelector(":scope > .b3-image-viewer-fit");
270
+ if (fit)
271
+ fit.hidden = fitted;
272
+ }
273
+ #zoomAbout(next, cx, cy, box = this.#measure()) {
274
+ if (!box)
275
+ return;
276
+ const before = this.#scale;
277
+ next = Math.max(1, Math.min(this.maxScale, next));
278
+ const ux = (cx - this.#tx) / this.#scale, uy = (cy - this.#ty) / this.#scale;
279
+ this.#tx = cx - ux * next;
280
+ this.#ty = cy - uy * next;
281
+ this.#scale = next;
282
+ this.#clamp(box);
283
+ this.#apply(box);
284
+ if (before !== this.#scale)
285
+ this.#emitZoom();
286
+ }
287
+ #emitZoom() {
288
+ this.dispatchEvent(new CustomEvent("b3-zoom", {
289
+ bubbles: true,
290
+ composed: true,
291
+ detail: { scale: this.#scale, fitted: this.fitted, src: this.src }
292
+ }));
293
+ }
294
+ reset() {
295
+ const changed = this.#scale !== 1;
296
+ this.#scale = 1;
297
+ this.#tx = 0;
298
+ this.#ty = 0;
299
+ const box = this.#measure();
300
+ if (box)
301
+ this.#clamp(box);
302
+ this.#apply(box);
303
+ if (changed)
304
+ this.#emitZoom();
305
+ }
306
+ zoomTo(next) {
307
+ const box = this.#measure();
308
+ if (box)
309
+ this.#zoomAbout(next, box.vw / 2, box.vh / 2, box);
310
+ }
311
+ requestClose() {
312
+ this.dispatchEvent(new CustomEvent("b3-close", { bubbles: true, composed: true, detail: { src: this.src } }));
313
+ }
314
+ #centroid() {
315
+ let x = 0, y = 0;
316
+ for (const point of this.#pointers.values()) {
317
+ x += point.x;
318
+ y += point.y;
319
+ }
320
+ return { x: x / this.#pointers.size, y: y / this.#pointers.size };
321
+ }
322
+ #spread() {
323
+ const [a, b] = [...this.#pointers.values()];
324
+ return Math.hypot(a.x - b.x, a.y - b.y);
325
+ }
326
+ #listen() {
327
+ this.#controller = new AbortController;
328
+ const options = { signal: this.#controller.signal };
329
+ const passive = { ...options, passive: false };
330
+ this.addEventListener("pointerdown", (event) => {
331
+ if (event.pointerType === "mouse" && event.button !== 0)
332
+ return;
333
+ if (!this.#content())
334
+ return;
335
+ this.#pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
336
+ this.#base = this.#measure();
337
+ if (this.#pointers.size > 1 || this.#scale > FIT || this.#overflows) {
338
+ this.setPointerCapture(event.pointerId);
339
+ event.preventDefault();
340
+ }
341
+ }, options);
342
+ this.addEventListener("pointermove", (event) => {
343
+ if (!this.#pointers.has(event.pointerId))
344
+ return;
345
+ const two = this.#pointers.size === 2;
346
+ if (!two && this.#scale <= FIT && !this.#overflows) {
347
+ this.#pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
348
+ return;
349
+ }
350
+ this.#base ??= this.#measure();
351
+ if (!this.#base)
352
+ return;
353
+ event.preventDefault();
354
+ const previousCentre = this.#centroid();
355
+ const previousSpread = two ? this.#spread() : 0;
356
+ this.#pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
357
+ const centre = this.#centroid();
358
+ const spread = two ? this.#spread() : 0;
359
+ const viewport = this.getBoundingClientRect();
360
+ const before = this.#scale;
361
+ const next = two && previousSpread > 0 ? Math.max(1, Math.min(this.maxScale, this.#scale * (spread / previousSpread))) : this.#scale;
362
+ const ux = (previousCentre.x - viewport.left - this.#tx) / this.#scale;
363
+ const uy = (previousCentre.y - viewport.top - this.#ty) / this.#scale;
364
+ this.#tx = centre.x - viewport.left - ux * next;
365
+ this.#ty = centre.y - viewport.top - uy * next;
366
+ this.#scale = next;
367
+ this.#clamp(this.#base);
368
+ this.#apply(this.#base);
369
+ this.#didGesture = true;
370
+ if (before !== this.#scale)
371
+ this.#emitZoom();
372
+ }, passive);
373
+ const release = (event) => {
374
+ if (!this.#pointers.delete(event.pointerId))
375
+ return;
376
+ if (this.hasPointerCapture?.(event.pointerId))
377
+ this.releasePointerCapture(event.pointerId);
378
+ if (this.#pointers.size) {
379
+ this.#base = this.#measure();
380
+ return;
381
+ }
382
+ this.#base = null;
383
+ if (this.#didGesture) {
384
+ this.#gestureEndedAt = performance.now();
385
+ this.#didGesture = false;
386
+ return;
387
+ }
388
+ if (event.type !== "pointerup")
389
+ return;
390
+ const now = performance.now();
391
+ const near = Math.hypot(event.clientX - this.#lastTapX, event.clientY - this.#lastTapY) < DOUBLE_TAP_SLOP;
392
+ if (now - this.#lastTap < DOUBLE_TAP_MS && near) {
393
+ this.#lastTap = 0;
394
+ const viewport = this.getBoundingClientRect();
395
+ this.#zoomAbout(this.#scale > FIT ? 1 : 2.5, event.clientX - viewport.left, event.clientY - viewport.top);
396
+ this.#gestureEndedAt = now;
397
+ } else {
398
+ this.#lastTap = now;
399
+ this.#lastTapX = event.clientX;
400
+ this.#lastTapY = event.clientY;
401
+ }
402
+ };
403
+ this.addEventListener("pointerup", release, options);
404
+ this.addEventListener("pointercancel", release, options);
405
+ this.addEventListener("click", (event) => {
406
+ if (event.target.closest?.(".b3-image-viewer-close, .b3-image-viewer-fit"))
407
+ return;
408
+ if (performance.now() - this.#gestureEndedAt < CLICK_SWALLOW_MS) {
409
+ event.preventDefault();
410
+ event.stopPropagation();
411
+ return;
412
+ }
413
+ if (this.open && this.fitted && event.target === this)
414
+ this.requestClose();
415
+ }, { ...options, capture: true });
416
+ this.addEventListener("wheel", (event) => {
417
+ const viewport = this.getBoundingClientRect();
418
+ if (event.ctrlKey) {
419
+ event.preventDefault();
420
+ this.#zoomAbout(this.#scale * Math.exp(-event.deltaY / 180), event.clientX - viewport.left, event.clientY - viewport.top);
421
+ return;
422
+ }
423
+ const box = this.#measure();
424
+ if (!box || box.h * this.#scale <= box.vh && !this.#overflows)
425
+ return;
426
+ const before = this.#ty;
427
+ this.#ty -= event.deltaY;
428
+ this.#clamp(box);
429
+ if (this.#ty === before)
430
+ return;
431
+ event.preventDefault();
432
+ this.#apply(box);
433
+ }, passive);
434
+ for (const type of ["gesturestart", "gesturechange", "gestureend"])
435
+ this.addEventListener(type, (event) => event.preventDefault(), passive);
436
+ this.addEventListener("keydown", (event) => {
437
+ if (event.metaKey || event.altKey)
438
+ return;
439
+ const step = event.shiftKey ? 80 : 40;
440
+ const pan = (dx, dy) => {
441
+ const box = this.#measure();
442
+ if (!box || this.fitted)
443
+ return false;
444
+ this.#tx += dx;
445
+ this.#ty += dy;
446
+ this.#clamp(box);
447
+ this.#apply(box);
448
+ return true;
449
+ };
450
+ switch (event.key) {
451
+ case "+":
452
+ case "=":
453
+ this.zoomTo(this.#scale * 1.5);
454
+ break;
455
+ case "-":
456
+ case "_":
457
+ this.zoomTo(this.#scale / 1.5);
458
+ break;
459
+ case "0":
460
+ this.reset();
461
+ break;
462
+ case "Enter":
463
+ case " ":
464
+ this.zoomTo(this.fitted ? 2.5 : 1);
465
+ break;
466
+ case "Escape":
467
+ if (!this.open)
468
+ return;
469
+ this.requestClose();
470
+ break;
471
+ case "ArrowLeft":
472
+ if (!pan(step, 0))
473
+ return;
474
+ break;
475
+ case "ArrowRight":
476
+ if (!pan(-step, 0))
477
+ return;
478
+ break;
479
+ case "ArrowUp":
480
+ if (!pan(0, step))
481
+ return;
482
+ break;
483
+ case "ArrowDown":
484
+ if (!pan(0, -step))
485
+ return;
486
+ break;
487
+ default:
488
+ return;
489
+ }
490
+ event.preventDefault();
491
+ }, options);
492
+ this.ownerDocument.defaultView.addEventListener("resize", () => {
493
+ const box = this.#measure();
494
+ if (box) {
495
+ this.#clamp(box);
496
+ this.#apply(box);
497
+ }
498
+ }, options);
499
+ }
500
+ }
501
+ var unused = B3ImageViewer;
502
+ function define(name = "b3-image-viewer") {
503
+ const existing = customElements.get(name);
504
+ if (existing)
505
+ return existing;
506
+ const constructor = unused;
507
+ unused = class extends B3ImageViewer {
508
+ };
509
+ customElements.define(name, constructor);
510
+ return constructor;
511
+ }
512
+ define();
513
+ export {
514
+ B3ImageViewer,
515
+ define
516
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-alpha.34",
2
+ "version": "0.1.0-alpha.35",
3
3
  "cssSha256": "b75401967cdd0259eddaf99fb81b78c466d531451278410df3cb8b9e0978ae62",
4
4
  "classes": [
5
5
  "b3-accordion",
@@ -452,6 +452,7 @@
452
452
  "cross-screen.md",
453
453
  "editable-filtered-rows.md",
454
454
  "icons.md",
455
+ "image-viewer.md",
455
456
  "motion.md",
456
457
  "scroll-entry.md",
457
458
  "start.md"
@@ -0,0 +1,66 @@
1
+ # Image viewer element
2
+
3
+ `<b3-image-viewer>` is an opt-in custom element for the one interaction markup and CSS cannot
4
+ carry: pinch-zoom, pan and double-tap on an image. It is not part of `ui.js`; import it only on
5
+ pages that need it.
6
+
7
+ ```html
8
+ <link rel="stylesheet" href="@livx.cc/bare-v3/css">
9
+ <script type="module" src="@livx.cc/bare-v3/elements/image-viewer"></script>
10
+
11
+ <!-- Without the module this is just the <img>, at its natural size. -->
12
+ <b3-image-viewer alt="Site plan" max-scale="8" style="height:320px">
13
+ <img src="/plan.png" alt="Site plan">
14
+ </b3-image-viewer>
15
+ ```
16
+
17
+ Light DOM, no shadow root: bare.css tokens, `data-b3-theme` and your own selectors all reach
18
+ inside. Always author the `<img>` yourself — that child is the degraded experience.
19
+
20
+ ## API
21
+
22
+ | Attribute | Meaning |
23
+ | --- | --- |
24
+ | `src` | Image source; also settable as a property when a framework hands you an object. |
25
+ | `alt` | Forwarded to the image, and used as the host's accessible name. |
26
+ | `max-scale` | Ceiling for zoom. Default `8`; a value below `1` is ignored. The floor is always fit. |
27
+ | `open` | Presence renders a full-screen overlay with a close button. |
28
+
29
+ Properties: `scale` and `fitted` (read-only), `src`, `alt`, `maxScale`, `open`.
30
+ Methods: `reset()`, `zoomTo(scale)`, `requestClose()`.
31
+ Events: `b3-zoom` (`detail: { scale, fitted, src }`), `b3-close`, and `b3-error`
32
+ (`detail: { src }`) when the image fails to load. All are plain bubbling `CustomEvent`s, so they
33
+ work without a framework wrapper. A failed image also sets `data-b3-error` on the host, so a
34
+ fallback can be pure CSS.
35
+
36
+ The host is `role="group"`, focusable, and named from `alt` (falling back to `Zoomable image`);
37
+ pass your own `aria-label` or `aria-labelledby` to override. An image the element creates for you
38
+ always gets an `alt`, empty when the host has none.
39
+
40
+ ### Another tag name
41
+
42
+ `import { define } from '@livx.cc/bare-v3/elements/image-viewer'` and call `define('my-viewer')`.
43
+ The default tag registers on import; each further name gets its own subclass, so naming is free.
44
+ Styling follows the `b3-image-viewer` class the element adds to itself, not the tag, so a renamed
45
+ viewer looks the same.
46
+
47
+ The element never closes itself: `b3-close` is a request, and the owner removes `open`. That keeps
48
+ the overlay's visibility wherever the app's state already lives.
49
+
50
+ ## Gestures
51
+
52
+ Pinch to zoom around the fingers' midpoint, one finger to pan once there is somewhere to pan,
53
+ double-tap to toggle fit and 2.5x, ctrl+wheel to zoom about the cursor, plain wheel to pan.
54
+ Keyboard: `+`/`-` zoom, `0` refits, arrows pan, `Enter` toggles, `Escape` requests close.
55
+
56
+ While fitted, `touch-action` stays `pan-y` so a one-finger drag still scrolls the page; it becomes
57
+ `none` only once the image can actually move.
58
+
59
+ ## Frameworks
60
+
61
+ Vue 3 binds and listens natively — `:src`, `:open="open || null"`, `@b3-zoom` — once
62
+ `app.config.compilerOptions.isCustomElement` accepts the `b3-` prefix.
63
+
64
+ React 18 has no custom-element property or event support. Attach listeners on a ref, and pass
65
+ `open={isOpen ? '' : undefined}`: React 18 stringifies `open={false}` into `open="false"`, which is
66
+ a *present* attribute and therefore an open overlay. React 19 removes both caveats.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/bare-v3",
3
- "version": "0.1.0-alpha.34",
3
+ "version": "0.1.0-alpha.35",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,8 @@
11
11
  "./css/min": "./dist/bare.min.css",
12
12
  "./manifest": "./dist/manifest.json",
13
13
  "./references/*": "./dist/references/*",
14
- "./ui": "./dist/ui.js"
14
+ "./ui": "./dist/ui.js",
15
+ "./elements/image-viewer": "./dist/elements/image-viewer.js"
15
16
  },
16
17
  "scripts": {
17
18
  "build": "bun scripts/build.ts",