@michaelyagi/shoji 0.1.0-alpha.10

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/esm/core/EventBus.d.ts +16 -0
  4. package/dist/esm/core/FocusTrap.d.ts +9 -0
  5. package/dist/esm/core/Gallery.d.ts +221 -0
  6. package/dist/esm/core/GestureController.d.ts +57 -0
  7. package/dist/esm/core/LiveRegion.d.ts +6 -0
  8. package/dist/esm/core/SlideManager.d.ts +85 -0
  9. package/dist/esm/core/bodyScrollLock.d.ts +2 -0
  10. package/dist/esm/core/dom.d.ts +27 -0
  11. package/dist/esm/core/icons.d.ts +6 -0
  12. package/dist/esm/core/index.d.ts +5 -0
  13. package/dist/esm/core/index.js +2105 -0
  14. package/dist/esm/core/index.js.map +1 -0
  15. package/dist/esm/core/plugin.d.ts +54 -0
  16. package/dist/esm/core/rotateFlipNormalize.d.ts +19 -0
  17. package/dist/esm/core/scan.d.ts +19 -0
  18. package/dist/esm/core/types.d.ts +290 -0
  19. package/dist/esm/core/zoomTransition.d.ts +33 -0
  20. package/dist/esm/gestures/GestureEngine.d.ts +78 -0
  21. package/dist/esm/index.css +540 -0
  22. package/dist/esm/index.d.ts +32 -0
  23. package/dist/esm/index.js +24 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/index2.css +22 -0
  26. package/dist/esm/index3.css +170 -0
  27. package/dist/esm/index4.css +27 -0
  28. package/dist/esm/plugins/activeThumbnail/index.d.ts +26 -0
  29. package/dist/esm/plugins/activeThumbnail/index.js +63 -0
  30. package/dist/esm/plugins/activeThumbnail/index.js.map +1 -0
  31. package/dist/esm/plugins/autoplay/icons.d.ts +3 -0
  32. package/dist/esm/plugins/autoplay/index.d.ts +19 -0
  33. package/dist/esm/plugins/autoplay/index.js +207 -0
  34. package/dist/esm/plugins/autoplay/index.js.map +1 -0
  35. package/dist/esm/plugins/fullscreen/icons.d.ts +3 -0
  36. package/dist/esm/plugins/fullscreen/index.d.ts +17 -0
  37. package/dist/esm/plugins/fullscreen/index.js +74 -0
  38. package/dist/esm/plugins/fullscreen/index.js.map +1 -0
  39. package/dist/esm/plugins/layout/index.d.ts +191 -0
  40. package/dist/esm/plugins/layout/index.js +752 -0
  41. package/dist/esm/plugins/layout/index.js.map +1 -0
  42. package/dist/esm/plugins/layout/justified.d.ts +68 -0
  43. package/dist/esm/plugins/layout/masonry.d.ts +92 -0
  44. package/dist/esm/plugins/rotateFlip/icons.d.ts +5 -0
  45. package/dist/esm/plugins/rotateFlip/index.d.ts +17 -0
  46. package/dist/esm/plugins/rotateFlip/index.js +138 -0
  47. package/dist/esm/plugins/rotateFlip/index.js.map +1 -0
  48. package/dist/esm/plugins/video/index.d.ts +13 -0
  49. package/dist/esm/plugins/video/index.js +207 -0
  50. package/dist/esm/plugins/video/index.js.map +1 -0
  51. package/dist/esm/plugins/video/vimeo.d.ts +43 -0
  52. package/dist/esm/plugins/video/youtube.d.ts +61 -0
  53. package/dist/esm/plugins/zoom/icons.d.ts +4 -0
  54. package/dist/esm/plugins/zoom/index.d.ts +30 -0
  55. package/dist/esm/plugins/zoom/index.js +274 -0
  56. package/dist/esm/plugins/zoom/index.js.map +1 -0
  57. package/dist/esm/plugins/zoom/zoomMath.d.ts +24 -0
  58. package/dist/esm/transitions/SlideTransition.d.ts +25 -0
  59. package/dist/esm/transitions/presets.d.ts +21 -0
  60. package/dist/esm/zoomTransition-bbKHpVpA.js +110 -0
  61. package/dist/esm/zoomTransition-bbKHpVpA.js.map +1 -0
  62. package/dist/shoji.css +759 -0
  63. package/dist/shoji.js +3907 -0
  64. package/dist/shoji.js.map +1 -0
  65. package/dist/shoji.min.css +1 -0
  66. package/dist/shoji.min.js +2 -0
  67. package/dist/shoji.min.js.map +1 -0
  68. package/package.json +77 -0
package/dist/shoji.css ADDED
@@ -0,0 +1,759 @@
1
+ /* DESIGN.md §4-autoplay — thin progress bar along the bottom edge, tracking
2
+ time-to-next-advance for timed (image) slides. Hidden during video slides,
3
+ where the video's own runtime — not a fixed interval — drives advancement. */
4
+ .shoji-autoplay-progress {
5
+ position: absolute;
6
+ left: 0;
7
+ right: 0;
8
+ bottom: 0;
9
+ height: 3px;
10
+ background: rgba(255, 255, 255, 0.15);
11
+ z-index: var(--shoji-z-toolbar);
12
+ }
13
+
14
+ .shoji-autoplay-progress[hidden] {
15
+ display: none;
16
+ }
17
+
18
+ .shoji-autoplay-progress-bar {
19
+ height: 100%;
20
+ width: 0%;
21
+ background: var(--shoji-progress);
22
+ }
23
+ /* DESIGN.md §5 — layout takes over the container entirely once enabled
24
+ (any host markup that was there at scan time is replaced by its own
25
+ generated tiles), so all of its structural styling lives here rather than
26
+ depending on host CSS. */
27
+
28
+ .shoji-layout {
29
+ position: relative;
30
+ }
31
+
32
+ /* A real, reported slowdown: opening/closing the lightbox reads layout
33
+ (getBoundingClientRect/offsetHeight — zoomTransition.ts's FLIP technique)
34
+ which forces the browser to flush *any* pending layout on the page first,
35
+ including this container's own (often large — hundreds of tiles)
36
+ subtree, even though none of that subtree's layout ever changed.
37
+ `contain: layout` tells the browser this subtree's internal layout can't
38
+ affect, and isn't affected by, anything outside it, so that flush can
39
+ skip descending into it. Originally applied only to masonry/justified —
40
+ grid was excluded on the assumption that layout containment couldn't
41
+ safely decouple an intrinsically-sized (content-driven auto-height) box.
42
+ Verified directly that assumption doesn't hold: `contain: layout style`
43
+ on `.shoji-layout--grid` leaves its rendered height and every tile's
44
+ position byte-for-byte identical, at 300 tiles, both with and without
45
+ it — containment isolates the subtree from *outside* influence, it
46
+ doesn't stop the box from sizing itself off its own children's natural
47
+ layout. Applies to all three types now. */
48
+ .shoji-layout--masonry,
49
+ .shoji-layout--justified,
50
+ .shoji-layout--grid {
51
+ contain: layout style;
52
+ }
53
+
54
+ .shoji-layout-tile {
55
+ display: block;
56
+ cursor: pointer;
57
+ }
58
+
59
+ .shoji-layout-tile img {
60
+ display: block;
61
+ width: 100%;
62
+ height: 100%;
63
+ object-fit: cover;
64
+ }
65
+
66
+ /* masonry & justified: both JS-positioned, absolutely placed, GPU-composited
67
+ moves only (transform), per CLAUDE.md — no top/left animation.
68
+ visibility: hidden until the JS layout pass actually sets a real
69
+ width/height/transform — masonry/justified skip that pass entirely
70
+ (index.ts's containerWidth <= 0 guard) if the container isn't sized yet
71
+ at construction, correcting only once the ResizeObserver's own
72
+ (asynchronous) initial notification fires. In that gap, an unstyled
73
+ tile falls back to CSS auto-sizing from its <img> child, which can be a
74
+ genuinely 0×0 or wildly wrong-shaped rect depending on image load
75
+ timing — clickable, but a click landing on it computes the zoom
76
+ transition's origin from that same bad rect, silently skipping the
77
+ animation (a 0×0 origin) or animating from the wrong place (a
78
+ wrong-shaped one). visibility: hidden (not display: none — keeps the
79
+ space-not-taking difference irrelevant here, but note it also removes
80
+ the tile from hit-testing, unlike opacity: 0) makes an unpositioned
81
+ tile un-clickable for that entire window instead — the JS layout
82
+ functions clear it back to visible the moment they actually set real
83
+ geometry. See DESIGN.md §2.3b. */
84
+ .shoji-layout--masonry .shoji-layout-tile,
85
+ .shoji-layout--justified .shoji-layout-tile {
86
+ position: absolute;
87
+ top: 0;
88
+ left: 0;
89
+ visibility: hidden;
90
+ }
91
+
92
+ .shoji-layout--masonry.shoji-layout--animate .shoji-layout-tile,
93
+ .shoji-layout--justified.shoji-layout--animate .shoji-layout-tile {
94
+ transition: transform var(--shoji-duration) var(--shoji-easing);
95
+ }
96
+
97
+ /* grid: native CSS grid does the positioning, no JS math or transforms. */
98
+ .shoji-layout--grid {
99
+ display: grid;
100
+ grid-template-columns: repeat(auto-fill, minmax(var(--shoji-layout-column-width, 240px), 1fr));
101
+ gap: var(--shoji-layout-gutter, 8px);
102
+ }
103
+
104
+ .shoji-layout--grid .shoji-layout-tile {
105
+ aspect-ratio: var(--shoji-layout-tile-aspect, 1);
106
+ }
107
+
108
+ /* headings (groupBy) — grid: a real grid row of its own, native flow, no JS
109
+ positioning needed (a full-span item naturally starts a new row after
110
+ it). masonry/justified: JS-positioned same as tiles, since those modes
111
+ don't have a document-flow "row" to insert into. */
112
+ .shoji-layout-heading {
113
+ margin: 0;
114
+ }
115
+
116
+ .shoji-layout--grid .shoji-layout-heading {
117
+ grid-column: 1 / -1;
118
+ }
119
+
120
+ .shoji-layout--masonry .shoji-layout-heading,
121
+ .shoji-layout--justified .shoji-layout-heading {
122
+ position: absolute;
123
+ top: 0;
124
+ left: 0;
125
+ width: 100%;
126
+ }
127
+
128
+ /* justified headings are compact, inline labels, not full-width blocking
129
+ elements (see the groupBy option's doc comment in index.ts) — sized to
130
+ their own content by default, and to their own JS-computed max-width
131
+ only when the content-fitting pass decides a label needs to wrap. This
132
+ overrides the shared width: 100% rule above by source order (equal
133
+ specificity), not by a stronger selector. */
134
+ .shoji-layout--justified .shoji-layout-heading {
135
+ width: auto;
136
+ white-space: nowrap;
137
+ }
138
+
139
+ .shoji-layout--masonry.shoji-layout--animate .shoji-layout-heading,
140
+ .shoji-layout--justified.shoji-layout--animate .shoji-layout-heading {
141
+ transition: transform var(--shoji-duration) var(--shoji-easing);
142
+ }
143
+
144
+ /* Font customization points, per CLAUDE.md ("all styling through --shoji-*
145
+ custom properties with sane defaults"). .shoji-layout-heading itself (the
146
+ plain-string/default heading, and the shared parent of title+subtitle)
147
+ deliberately keeps its browser-default <h2> font-size untouched rather
148
+ than gaining its own --shoji-layout-heading-font-size — overriding that
149
+ here to "inherit" would shrink it from the UA stylesheet's own (larger)
150
+ h2 default, a visible regression for anyone not using the structured
151
+ form. Host CSS targeting .shoji-layout-heading directly already fully
152
+ controls it.
153
+
154
+ The title/subtitle spans below intentionally do NOT size off that same
155
+ (oversized, unreset) h2 default — title and subtitle both get an
156
+ explicit shared default size instead (matching .shoji-counter's own
157
+ 0.875rem elsewhere in core, for visual consistency), decoupled from
158
+ whatever the plain-string heading's own size happens to be. The label
159
+ (title) reads as muted/normal-weight, the value (subtitle) as bold/
160
+ full-color — e.g. "Taken" vs a bolded date — the inverse of an earlier
161
+ version of this default that bolded the label instead. */
162
+ .shoji-layout-heading {
163
+ font-family: var(--shoji-layout-heading-font-family, inherit);
164
+ }
165
+
166
+ .shoji-layout-heading-title {
167
+ font-size: var(--shoji-layout-heading-title-font-size, 0.875rem);
168
+ font-weight: var(--shoji-layout-heading-title-font-weight, 400);
169
+ color: var(--shoji-layout-heading-title-color, #767676);
170
+ /* headingOverflow: 'fit''s ellipsis fallback (the one unavoidable case —
171
+ a single label wider than the container even alone) still needs
172
+ overflow-wrap: break-word so a single long unbroken token (a URL, a
173
+ hashtag) doesn't itself force the ellipsis box wider than intended. */
174
+ overflow-wrap: break-word;
175
+ }
176
+
177
+ .shoji-layout-heading-subtitle {
178
+ margin-left: 0.6em;
179
+ font-size: var(--shoji-layout-heading-subtitle-font-size, 0.875rem);
180
+ font-weight: var(--shoji-layout-heading-subtitle-font-weight, 700);
181
+ color: var(--shoji-layout-heading-subtitle-color, inherit);
182
+ overflow-wrap: break-word;
183
+ }
184
+
185
+ /* stickyHeadings — grid mode only; masonry/justified headings are already
186
+ absolutely positioned by JS, which sticky can't meaningfully layer onto
187
+ (see the option's own doc comment in index.ts). */
188
+ .shoji-layout--grid .shoji-layout-heading--sticky {
189
+ position: sticky;
190
+ top: 0;
191
+ z-index: 1;
192
+ }
193
+ /* DESIGN.md §4-zoom — cursor affordances only; the transform itself is set
194
+ inline (per-slide, computed from gesture/button state, not a static
195
+ rule). Scoped to .shoji-zoom-enabled (added by the plugin itself, per
196
+ slide) rather than the bare .shoji-slide-img class — that class exists on
197
+ every slide image regardless of whether this plugin is even enabled for
198
+ a given gallery, and this CSS ships in the single-file bundle alongside
199
+ every other official plugin's, so an unscoped rule would leak a zoom-in
200
+ cursor onto galleries that never opted into this plugin at all. */
201
+ .shoji-zoom-enabled {
202
+ cursor: zoom-in;
203
+ }
204
+
205
+ .shoji-zoom-enabled.shoji-zoomed {
206
+ cursor: grab;
207
+ /* Reported from real usage: a scaled-up image could show GPU tile-seam
208
+ lines through its own content while zoomed — the same rasterization
209
+ quirk as the toolbar's own (shoji.css), just from the image's frequent
210
+ scale() updates instead of a neighbor's. Scoped to only while actually
211
+ zoomed (not the whole time this plugin is enabled) — at scale 1 there's
212
+ no scale-up to tile-seam in the first place, so no reason to spend the
213
+ extra memory a promoted layer costs. */
214
+ will-change: transform;
215
+ }
216
+
217
+ .shoji-zoom-enabled.shoji-zoomed:active {
218
+ cursor: grabbing;
219
+ }
220
+ /**
221
+ * Core theme tokens (DESIGN.md §9). Dark is the default theme; `.shoji-light`
222
+ * overrides to light. No hardcoded colors/sizes elsewhere in JS or CSS —
223
+ * everything routes through these custom properties.
224
+ *
225
+ * `.shoji-outer` always mounts directly on `document.body` (Gallery.ts),
226
+ * never nested inside the host's own gallery container — so `.shoji-light`
227
+ * (or any `--shoji-*` override) must be applied to `<body>`/`<html>`/another
228
+ * real ancestor of the mount point, not the gallery container itself, or it
229
+ * silently does nothing (custom properties only inherit down real DOM
230
+ * ancestry).
231
+ */
232
+ :root {
233
+ /* .shoji-outer's own page-level z-index — relative to the rest of the
234
+ host page, not to anything inside the lightbox. Real pages routinely
235
+ put sticky navbars/sidebars/toast containers at 1000-9999, so this
236
+ needs real headroom, not the small internal scale below (which only
237
+ orders backdrop/slides/overlay/toolbar/toast *against each other*,
238
+ inside the stacking context .shoji-outer's own z-index establishes). */
239
+ --shoji-z-base: 9000;
240
+
241
+ --shoji-z-backdrop: 0;
242
+ --shoji-z-slides: 10;
243
+ --shoji-z-overlay: 20;
244
+ --shoji-z-toolbar: 30;
245
+ --shoji-z-toast: 40;
246
+
247
+ --shoji-color-bg: #0b0b0d;
248
+ --shoji-color-fg: #f5f5f5;
249
+ --shoji-color-accent: #4da3ff;
250
+ --shoji-color-backdrop: rgba(0, 0, 0, 0.92);
251
+ --shoji-color-toolbar-bg: rgba(20, 20, 22, 0.85);
252
+ --shoji-color-disabled: rgba(245, 245, 245, 0.35);
253
+
254
+ --shoji-focus-ring: 2px solid var(--shoji-color-accent);
255
+ --shoji-progress: var(--shoji-color-accent);
256
+
257
+ --shoji-spacing-sm: 0.5rem;
258
+ --shoji-spacing-md: 1rem;
259
+ --shoji-spacing-lg: 1.5rem;
260
+
261
+ --shoji-easing: cubic-bezier(0.4, 0, 0.2, 1);
262
+ --shoji-duration: 300ms;
263
+ /* Caps how tall the caption box can grow before it scrolls internally —
264
+ an unbounded caption on a long string would otherwise creep up over a
265
+ video slide's native control bar underneath it (bottom-left, same
266
+ corner), silently swallowing scrub/volume/fullscreen clicks with no
267
+ way to reach them. `min()` also bounds it on a short/wide dialog
268
+ (mobile landscape), where a `rem`-only cap could still eat most of
269
+ the available height. */
270
+ --shoji-caption-max-height: min(8rem, 30%);
271
+ /* Reserved space at the top of a provider embed (YouTube etc.) so its own
272
+ hover-revealed title bar never sits under Shoji's toolbar — matches the
273
+ toolbar's own rendered height (44px button + top/bottom padding).
274
+ Permanent, not tied to the toolbar's auto-hide state, so the video
275
+ never resizes as the toolbar fades in/out. HTML5 video has no top-of-
276
+ player UI at all, so it's untouched. */
277
+ --shoji-provider-video-top-inset: calc(44px + 2 * var(--shoji-spacing-sm));
278
+ /* DESIGN.md §2.4 — the gesture engine's own documented default easing for
279
+ drag-to-navigate/drag-to-close settle animations specifically (a snap
280
+ tuned for "released mid-motion," distinct from --shoji-easing's
281
+ open/close/zoom-transition curve). */
282
+ --shoji-momentum-easing: cubic-bezier(0.2, 0, 0, 1);
283
+
284
+ /* System-font stack: no webfont download, renders instantly, looks native
285
+ (San Francisco / Segoe UI / Roboto) on every platform. */
286
+ --shoji-font-family:
287
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
288
+ 'Apple Color Emoji', 'Segoe UI Emoji';
289
+ }
290
+
291
+ .shoji-light {
292
+ --shoji-color-bg: #fafafa;
293
+ --shoji-color-fg: #111111;
294
+ --shoji-color-backdrop: rgba(255, 255, 255, 0.96);
295
+ --shoji-color-toolbar-bg: rgba(255, 255, 255, 0.9);
296
+ --shoji-color-disabled: rgba(17, 17, 17, 0.35);
297
+ }
298
+
299
+ @media (prefers-reduced-motion: reduce) {
300
+ :root {
301
+ --shoji-duration: 0ms;
302
+ }
303
+ }
304
+
305
+ /* Lightbox structure (DESIGN.md §2.3 slides, §2.6 dialog). Built lazily on first open(). */
306
+ .shoji-outer {
307
+ display: none;
308
+ position: fixed;
309
+ inset: 0;
310
+ z-index: var(--shoji-z-base);
311
+ }
312
+
313
+ .shoji-outer.shoji-open {
314
+ display: block;
315
+ }
316
+
317
+ .shoji-backdrop {
318
+ position: absolute;
319
+ inset: 0;
320
+ z-index: var(--shoji-z-backdrop);
321
+ background: var(--shoji-color-backdrop);
322
+ }
323
+
324
+ .shoji-dialog {
325
+ position: relative;
326
+ z-index: var(--shoji-z-slides);
327
+ display: flex;
328
+ flex-direction: column;
329
+ height: 100%;
330
+ color: var(--shoji-color-fg);
331
+ font-family: var(--shoji-font-family);
332
+ outline: none;
333
+ /* DESIGN.md §2.4 — the gesture engine owns horizontal/vertical drag on
334
+ this element; without this, touch browsers intercept the same gesture
335
+ for native scroll/pull-to-refresh/back-navigation before JS ever sees
336
+ enough movement to lock a direction. Buttons/video inside remain
337
+ interactive regardless (this is about implicit browser gesture
338
+ handling, not pointer-events). */
339
+ touch-action: none;
340
+ }
341
+
342
+ .shoji-toolbar {
343
+ position: absolute;
344
+ inset: 0 0 auto 0;
345
+ z-index: var(--shoji-z-toolbar);
346
+ display: flex;
347
+ justify-content: space-between;
348
+ padding: var(--shoji-spacing-sm);
349
+ pointer-events: none;
350
+ /* Reported from real usage: the Zoom plugin's frequent scale() updates on
351
+ the photo (a sibling, not a descendant) could show as GPU tile-seam
352
+ artifacts (thin lines) through the toolbar's own semi-transparent,
353
+ border-radius buttons — the same class of rendering quirk already noted
354
+ for progressive image decode. Promoting the toolbar to its own stable
355
+ compositing layer, decoupled from the photo's repaints, avoids it. */
356
+ will-change: transform;
357
+ }
358
+
359
+ /* DESIGN.md §3 — ctx.ui.toolbar(slot, ...) inserts plugin buttons into these;
360
+ pointer-events re-enabled per-slot (not per-button) so gaps between slots
361
+ stay click-through even once a slot has content. `flex-wrap: wrap` (not
362
+ `overflow-x: auto`) is deliberate: several plugins loaded together on a
363
+ narrow/mobile viewport can add up to more 44px buttons than the width
364
+ allows (CLAUDE.md's own touch-target size, non-negotiable), and this
365
+ project treats horizontal scroll as something to design around, not
366
+ reach for (see the layout plugin's masonry `orientation: 'horizontal'`,
367
+ which was rebuilt specifically to avoid it) — wrapping to a second row
368
+ keeps every button reachable by a tap with no sideways scroll gesture
369
+ anywhere in the lightbox chrome either. */
370
+ .shoji-toolbar-slot {
371
+ display: flex;
372
+ flex-wrap: wrap;
373
+ align-items: center;
374
+ gap: var(--shoji-spacing-sm);
375
+ pointer-events: auto;
376
+ max-width: 45%;
377
+ }
378
+
379
+ .shoji-toolbar-left {
380
+ justify-content: flex-start;
381
+ }
382
+
383
+ .shoji-toolbar-right {
384
+ justify-content: flex-end;
385
+ }
386
+
387
+ .shoji-close,
388
+ .shoji-nav,
389
+ .shoji-toolbar-button {
390
+ appearance: none;
391
+ border: none;
392
+ padding: 0;
393
+ width: 44px;
394
+ height: 44px;
395
+ border-radius: 50%;
396
+ display: flex;
397
+ align-items: center;
398
+ justify-content: center;
399
+ background: rgba(0, 0, 0, 0.4);
400
+ color: var(--shoji-color-fg);
401
+ cursor: pointer;
402
+ }
403
+
404
+ .shoji-close:hover,
405
+ .shoji-nav:hover,
406
+ .shoji-toolbar-button:hover {
407
+ background: rgba(0, 0, 0, 0.6);
408
+ }
409
+
410
+ .shoji-close:focus-visible,
411
+ .shoji-nav:focus-visible,
412
+ .shoji-toolbar-button:focus-visible {
413
+ outline: var(--shoji-focus-ring);
414
+ outline-offset: 2px;
415
+ }
416
+
417
+ .shoji-nav {
418
+ position: absolute;
419
+ top: 50%;
420
+ transform: translateY(-50%);
421
+ z-index: var(--shoji-z-toolbar);
422
+ }
423
+
424
+ .shoji-nav-prev {
425
+ left: var(--shoji-spacing-md);
426
+ }
427
+
428
+ .shoji-nav-next {
429
+ right: var(--shoji-spacing-md);
430
+ }
431
+
432
+ .shoji-nav:disabled {
433
+ opacity: 0.3;
434
+ cursor: default;
435
+ pointer-events: none;
436
+ }
437
+
438
+ .shoji-close[hidden],
439
+ .shoji-nav[hidden],
440
+ .shoji-toolbar-button[hidden] {
441
+ display: none;
442
+ }
443
+
444
+ /* DESIGN.md §2.3 — while the active slide's own content is still loading,
445
+ feature/plugin toolbar buttons (zoom, rotate, fullscreen, autoplay, any
446
+ custom plugin's own button) dim and stop responding, since they'd
447
+ otherwise act on content about to be replaced. Deliberately scoped to
448
+ `.shoji-toolbar-button` only — `.shoji-close`/`.shoji-nav` are different
449
+ classes, so closing and navigating are never blocked by a slow image.
450
+ `pointer-events: none` here is the mouse half; Gallery.ts's matching
451
+ `tabIndex = -1` is the keyboard half (this alone wouldn't stop
452
+ Enter/Space activating an already-focused button). */
453
+ .shoji-outer.shoji-slide-loading .shoji-toolbar-button {
454
+ opacity: 0.3;
455
+ cursor: default;
456
+ pointer-events: none;
457
+ }
458
+
459
+ .shoji-slides {
460
+ position: relative;
461
+ flex: 1;
462
+ overflow: hidden;
463
+ }
464
+
465
+ .shoji-slide {
466
+ position: absolute;
467
+ inset: 0;
468
+ display: flex;
469
+ align-items: center;
470
+ justify-content: center;
471
+ /* transform (translateX per pool offset) is set inline by SlideManager */
472
+ }
473
+
474
+ .shoji-slide-media {
475
+ /* Definite size (not max-width/max-height alone) so the img's own
476
+ percentage-based max-width/max-height below has something concrete to
477
+ resolve against — otherwise it sizes to the image's natural dimensions
478
+ instead of being contained, and object-fit: contain never engages. */
479
+ width: 100%;
480
+ height: 100%;
481
+ display: flex;
482
+ align-items: center;
483
+ justify-content: center;
484
+ /* Anchors .shoji-video-play-overlay's absolute positioning to this box
485
+ specifically, not implicitly to whichever positioned ancestor happens
486
+ to be next up (currently .shoji-slide, coincidentally the same size —
487
+ explicit here so that stays true even if that ever changes). */
488
+ position: relative;
489
+ }
490
+
491
+ .shoji-slide-img,
492
+ .shoji-slide-video {
493
+ display: block;
494
+ max-width: 100%;
495
+ max-height: 100%;
496
+ object-fit: contain;
497
+ }
498
+
499
+ /* max-width/max-height above only ever *cap* size — a real photo is normally
500
+ bigger than the slide area, so that's correct. A low-res open() placeholder
501
+ (§2.3) is the opposite case: deliberately small, so it needs to be forced
502
+ up to fill the frame (blurry, on purpose) instead of rendering at its own
503
+ tiny natural size. object-fit: contain (inherited above) still letterboxes
504
+ it within that forced box, just per the thumbnail's own aspect ratio. */
505
+ .shoji-slide-open-placeholder {
506
+ width: 100%;
507
+ height: 100%;
508
+ }
509
+
510
+ /* DESIGN.md §4-video — a provider's embed (an <iframe>, typically) has no
511
+ natural size Shoji can measure the way a decoded image/video does. A real
512
+ bug, reported from real usage, found in two parts:
513
+ 1. Stretching the embed to fill this box regardless of the video's own
514
+ aspect ratio pushed the bottom of the video (and a provider's own
515
+ controls, always along that edge) below the visible area whenever the
516
+ box wasn't already the same shape as the video.
517
+ 2. Fixing that by fitting to the box's actual shape instead exposed a
518
+ second issue: neither YouTube's nor Vimeo's own player page renders
519
+ with a background matching Shoji's theme in the letterboxed gap
520
+ *inside* the iframe (both showed white) — a color this page has no
521
+ way to reach or override, since that content is cross-origin.
522
+ Assumes 16:9 (overwhelmingly the common case for both providers, and
523
+ exactly correct for the video this was diagnosed against) and *contains*
524
+ within whatever box is actually available — the same shape object-fit
525
+ already gives an <img>/<video> above, achieved differently here since
526
+ object-fit doesn't apply to an iframe (or the wrapper div Vimeo's own
527
+ SDK builds one inside) — so any remaining gap is outside the iframe
528
+ instead of inside it, showing Shoji's own dark backdrop through this
529
+ element's own transparent background rather than the provider's white. */
530
+ .shoji-slide-provider-video {
531
+ position: relative;
532
+ display: flex;
533
+ align-items: center;
534
+ justify-content: center;
535
+ width: 100%;
536
+ height: 100%;
537
+ box-sizing: border-box;
538
+ padding-top: var(--shoji-provider-video-top-inset);
539
+ /* Establishes `cqw`/`cqh` below as this element's own *content* box
540
+ (padding already excluded) — safe to size independently of children
541
+ since its own width/height already come from its parent, not from
542
+ whatever ends up inside it. */
543
+ container-type: size;
544
+ }
545
+
546
+ .shoji-slide-provider-video[hidden] {
547
+ display: none;
548
+ }
549
+
550
+ /* `.shoji-video-mount` — src/plugins/video/vimeo.ts's own wrapper div,
551
+ which its SDK would otherwise size itself (a small fixed default
552
+ normally, or its own `responsive: true` option's aspect-ratio-from-width-
553
+ alone computation, ignoring how much height is actually available either
554
+ way) — `!important` discards whatever it tries to set inline. The direct
555
+ `> iframe` case is YouTube, which has no such wrapper at all.
556
+ `min(100cqw, 100cqh * 16/9)`/`min(100cqh, 100cqw * 9/16)` is the
557
+ contain-within-either-axis formula `object-fit: contain` gives an
558
+ <img>/<video> natively — plain `aspect-ratio` alone can't do this for a
559
+ non-replaced element like a div/iframe: it only ever derives *one*
560
+ dimension from the other, so whichever of width/height stays a fixed
561
+ `100%` never actually shrinks even once the derived dimension gets
562
+ capped by `max-width`/`max-height`, silently distorting the ratio in
563
+ whichever direction wasn't the binding constraint. Confirmed directly at
564
+ both a wide-short viewport (height the binding constraint) and a
565
+ narrow-tall one (width the binding constraint) — container query units
566
+ are what actually solve both at once, not `aspect-ratio` by itself. */
567
+ .shoji-slide-provider-video > iframe,
568
+ .shoji-video-mount {
569
+ width: min(100cqw, calc(100cqh * 16 / 9)) !important;
570
+ height: min(100cqh, calc(100cqw * 9 / 16)) !important;
571
+ margin: 0 !important;
572
+ padding: 0 !important;
573
+ }
574
+
575
+ .shoji-slide-provider-video iframe {
576
+ display: block;
577
+ border: 0;
578
+ }
579
+
580
+ /* Once `.shoji-video-mount` above is already correctly contain-fitted, its
581
+ own iframe just needs to fill it completely — no `aspect-ratio` of its
582
+ own to fight the wrapper's. Same `!important`/static-not-absolute
583
+ reasoning as `.shoji-video-mount` — Vimeo's SDK may set its own inline
584
+ sizing/position on the iframe too, not just on the wrapper. */
585
+ .shoji-video-mount iframe {
586
+ position: static !important;
587
+ width: 100% !important;
588
+ height: 100% !important;
589
+ }
590
+
591
+ /* A paused video's own play affordance — native browser styling for this
592
+ varies too much to rely on alone (some show a big centered play icon,
593
+ some don't). Faint/translucent, same visual language as the toolbar's
594
+ own round icon buttons, just bigger — a primary, hard-to-miss affordance,
595
+ not a small chrome control. Deliberately not gated by any GalleryOptions
596
+ flag (purely presentational — hiding it changes nothing functionally,
597
+ the video is still clickable either way); a host not wanting it just
598
+ overrides this selector in their own CSS. */
599
+ .shoji-video-play-overlay {
600
+ position: absolute;
601
+ top: 50%;
602
+ left: 50%;
603
+ transform: translate(-50%, -50%);
604
+ width: var(--shoji-video-overlay-size, 64px);
605
+ height: var(--shoji-video-overlay-size, 64px);
606
+ border-radius: 50%;
607
+ border: none;
608
+ padding: 0;
609
+ display: flex;
610
+ align-items: center;
611
+ justify-content: center;
612
+ background: var(--shoji-video-overlay-bg, rgba(0, 0, 0, 0.4));
613
+ color: var(--shoji-video-overlay-color, var(--shoji-color-fg));
614
+ cursor: pointer;
615
+ }
616
+
617
+ .shoji-video-play-overlay[hidden] {
618
+ display: none;
619
+ }
620
+
621
+ /* DESIGN.md §2.5 — the temporary clone SlideTransition animates the
622
+ outgoing slide's content out on. Sits above the normal (unpositioned
623
+ z-index) pool slots by DOM order alone, but z-index is set explicitly
624
+ here rather than relied on implicitly, matching how every other overlay
625
+ layer in this file states its stacking intent up front. Not interactive
626
+ (it's a decorative clone mid-removal) and never receives its own size —
627
+ it fills `.shoji-slides` exactly like the real `.shoji-slide` it's a
628
+ snapshot of, so the cloned `.shoji-slide-media` inside it (already
629
+ `width/height: 100%`) lines up pixel-for-pixel with no extra CSS needed. */
630
+ .shoji-slide-ghost {
631
+ position: absolute;
632
+ inset: 0;
633
+ z-index: 1;
634
+ pointer-events: none;
635
+ }
636
+
637
+ .shoji-slide-placeholder {
638
+ color: var(--shoji-color-disabled);
639
+ }
640
+
641
+ /* DESIGN.md §2.3 — shown in place of a slide's content while it's still
642
+ decoding/loading (outside the `preload` window, where nothing was ready
643
+ ahead of time). CSS-only rotation (no JS-driven layout, per CLAUDE.md);
644
+ disabled under prefers-reduced-motion the same as every other animation
645
+ here, rather than left spinning regardless. */
646
+ .shoji-slide-spinner {
647
+ width: var(--shoji-spinner-size, 40px);
648
+ height: var(--shoji-spinner-size, 40px);
649
+ border-radius: 50%;
650
+ border: var(--shoji-spinner-thickness, 3px) solid var(--shoji-color-disabled);
651
+ border-top-color: var(--shoji-color-accent);
652
+ animation: shoji-spin 0.8s linear infinite;
653
+ }
654
+
655
+ @keyframes shoji-spin {
656
+ to {
657
+ transform: rotate(360deg);
658
+ }
659
+ }
660
+
661
+ @media (prefers-reduced-motion: reduce) {
662
+ .shoji-slide-spinner {
663
+ animation: none;
664
+ }
665
+ }
666
+
667
+ /* A normal flex item within .shoji-toolbar-left (not independently
668
+ positioned) — .shoji-slides still gets the full dialog height either way,
669
+ no reserved gutter at top or bottom, since the toolbar itself overlays the
670
+ slides rather than pushing them down. Flowing alongside whatever else is
671
+ in that slot (e.g. a plugin's ctx.ui.toolbar('left', ...) button) keeps it
672
+ from overlapping and stealing clicks the way independent absolute
673
+ positioning did. */
674
+ .shoji-counter {
675
+ padding: 0.25rem 0.6rem;
676
+ border-radius: 999px;
677
+ background: rgba(0, 0, 0, 0.4);
678
+ font-size: 0.875rem;
679
+ white-space: nowrap;
680
+ }
681
+
682
+ .shoji-counter[hidden] {
683
+ display: none;
684
+ }
685
+
686
+ .shoji-caption {
687
+ position: absolute;
688
+ bottom: var(--shoji-spacing-md);
689
+ left: var(--shoji-spacing-md);
690
+ z-index: var(--shoji-z-toolbar);
691
+ width: fit-content;
692
+ max-width: calc(100% - 2 * var(--shoji-spacing-md));
693
+ max-height: var(--shoji-caption-max-height);
694
+ overflow-y: auto;
695
+ overscroll-behavior: contain;
696
+ padding: var(--shoji-spacing-sm) var(--shoji-spacing-md);
697
+ border-radius: 8px;
698
+ background: var(--shoji-color-toolbar-bg);
699
+ text-align: left;
700
+ }
701
+
702
+ /* A real bug: height-capping the caption (above) still isn't enough on its
703
+ own — a video that fills most of the dialog leaves little to no
704
+ letterboxing gap, so even a short caption's opaque background can land
705
+ directly on the native control bar underneath it (same corner). Letting
706
+ clicks pass straight through the caption on a video slide (Gallery.ts
707
+ toggles this class from `item.video`, HTML5 or a provider like YouTube
708
+ alike) is what actually guarantees the controls stay reachable, not just
709
+ "usually clear of them." Re-enabled on any real child element so a rich
710
+ HTML caption's own links/buttons (DESIGN.md §2.1) stay clickable — only
711
+ a plain-string caption (no element children at all) ever fully passes
712
+ through. Photo slides are untouched: nothing below them needs protecting,
713
+ so the caption keeps its normal scrollable/selectable behavior there. */
714
+ .shoji-caption--video {
715
+ pointer-events: none;
716
+ }
717
+
718
+ .shoji-caption--video > * {
719
+ pointer-events: auto;
720
+ }
721
+
722
+ .shoji-caption[hidden] {
723
+ display: none;
724
+ }
725
+
726
+ /* DESIGN.md §2.8 — idle auto-hide. Opacity only, never display:none, never
727
+ removed from the tab order: a hidden-but-focused control stays interactive,
728
+ just visually faded (see Gallery's isControlFocused() guard). */
729
+ .shoji-toolbar,
730
+ .shoji-nav,
731
+ .shoji-counter,
732
+ .shoji-caption {
733
+ transition: opacity var(--shoji-duration) var(--shoji-easing);
734
+ }
735
+
736
+ .shoji-controls-hidden .shoji-toolbar,
737
+ .shoji-controls-hidden .shoji-nav,
738
+ .shoji-controls-hidden .shoji-counter,
739
+ .shoji-controls-hidden .shoji-caption {
740
+ opacity: 0;
741
+ pointer-events: none;
742
+ }
743
+
744
+ /* !important: wins over any plugin's own cursor styling (e.g. zoom.css's
745
+ zoom-in/grab affordances) without core needing to know that plugin's
746
+ selectors — the hidden state means nothing should suggest interactivity. */
747
+ .shoji-controls-hidden,
748
+ .shoji-controls-hidden * {
749
+ cursor: none !important;
750
+ }
751
+
752
+ .shoji-live-region {
753
+ position: absolute;
754
+ width: 1px;
755
+ height: 1px;
756
+ overflow: hidden;
757
+ clip: rect(0 0 0 0);
758
+ white-space: nowrap;
759
+ }