@michaelyagi/shoji 0.1.0-beta.2 → 0.1.0-beta.3

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 (44) hide show
  1. package/dist/core/shoji-core.css +833 -0
  2. package/dist/core/shoji-core.js +3289 -0
  3. package/dist/core/shoji-core.js.map +1 -0
  4. package/dist/core/shoji-core.min.css +1 -0
  5. package/dist/core/shoji-core.min.js +2 -0
  6. package/dist/core/shoji-core.min.js.map +1 -0
  7. package/dist/esm/index.js +1 -1
  8. package/dist/plugins/activeThumbnail.js +78 -0
  9. package/dist/plugins/activeThumbnail.js.map +1 -0
  10. package/dist/plugins/activeThumbnail.min.js +2 -0
  11. package/dist/plugins/activeThumbnail.min.js.map +1 -0
  12. package/dist/plugins/autoplay.css +58 -0
  13. package/dist/plugins/autoplay.js +288 -0
  14. package/dist/plugins/autoplay.js.map +1 -0
  15. package/dist/plugins/autoplay.min.css +1 -0
  16. package/dist/plugins/autoplay.min.js +2 -0
  17. package/dist/plugins/autoplay.min.js.map +1 -0
  18. package/dist/plugins/fullscreen.js +80 -0
  19. package/dist/plugins/fullscreen.js.map +1 -0
  20. package/dist/plugins/fullscreen.min.js +2 -0
  21. package/dist/plugins/fullscreen.min.js.map +1 -0
  22. package/dist/plugins/layout.css +170 -0
  23. package/dist/plugins/layout.js +767 -0
  24. package/dist/plugins/layout.js.map +1 -0
  25. package/dist/plugins/layout.min.css +1 -0
  26. package/dist/plugins/layout.min.js +2 -0
  27. package/dist/plugins/layout.min.js.map +1 -0
  28. package/dist/plugins/rotateFlip.js +196 -0
  29. package/dist/plugins/rotateFlip.js.map +1 -0
  30. package/dist/plugins/rotateFlip.min.js +2 -0
  31. package/dist/plugins/rotateFlip.min.js.map +1 -0
  32. package/dist/plugins/video.js +227 -0
  33. package/dist/plugins/video.js.map +1 -0
  34. package/dist/plugins/video.min.js +2 -0
  35. package/dist/plugins/video.min.js.map +1 -0
  36. package/dist/plugins/zoom.css +27 -0
  37. package/dist/plugins/zoom.js +450 -0
  38. package/dist/plugins/zoom.js.map +1 -0
  39. package/dist/plugins/zoom.min.css +1 -0
  40. package/dist/plugins/zoom.min.js +2 -0
  41. package/dist/plugins/zoom.min.js.map +1 -0
  42. package/dist/shoji.js +1 -1
  43. package/dist/shoji.min.js +1 -1
  44. package/package.json +1 -1
@@ -0,0 +1,833 @@
1
+ /**
2
+ * Core theme tokens (DESIGN.md §9). Dark is the default theme; `.shoji-light`
3
+ * overrides to light. No hardcoded colors/sizes elsewhere in JS or CSS —
4
+ * everything routes through these custom properties.
5
+ *
6
+ * `.shoji-outer` always mounts directly on `document.body` (Gallery.ts),
7
+ * never nested inside the host's own gallery container — so `.shoji-light`
8
+ * (or any `--shoji-*` override) must be applied to `<body>`/`<html>`/another
9
+ * real ancestor of the mount point, not the gallery container itself, or it
10
+ * silently does nothing (custom properties only inherit down real DOM
11
+ * ancestry).
12
+ */
13
+ :root {
14
+ /* .shoji-outer's own page-level z-index — relative to the rest of the
15
+ host page, not to anything inside the lightbox. Real pages routinely
16
+ put sticky navbars/sidebars/toast containers at 1000-9999, so this
17
+ needs real headroom, not the small internal scale below (which only
18
+ orders backdrop/slides/overlay/toolbar/toast *against each other*,
19
+ inside the stacking context .shoji-outer's own z-index establishes). */
20
+ --shoji-z-base: 9000;
21
+
22
+ --shoji-z-backdrop: 0;
23
+ --shoji-z-slides: 10;
24
+ --shoji-z-overlay: 20;
25
+ --shoji-z-toolbar: 30;
26
+ --shoji-z-toast: 40;
27
+ --shoji-z-caption-modal: 50; /* DESIGN.md §2.3a — above everything else in the dialog, a nested dialog of its own */
28
+
29
+ --shoji-color-bg: #0b0b0d;
30
+ --shoji-color-fg: #f5f5f5;
31
+ --shoji-color-accent: #4da3ff;
32
+ /* Fully opaque hue — the backdrop's actual on-screen transparency is
33
+ --shoji-backdrop-opacity below, a real 0-1 alpha, not baked in here.
34
+ A custom theme is still free to use an rgba()/gradient value instead;
35
+ --shoji-backdrop-opacity then composites on top of that value's own
36
+ alpha (CSS opacity multiplies), same as it always has. */
37
+ --shoji-color-backdrop: #000000;
38
+ --shoji-color-toolbar-bg: rgba(20, 20, 22, 0.85);
39
+ --shoji-color-disabled: rgba(245, 245, 245, 0.35);
40
+ /* Real alpha: 1 = fully opaque, 0 = fully transparent. Settable directly
41
+ via GalleryOptions.backdropOpacity (Gallery.ts) without needing to know
42
+ or override the full color value. */
43
+ --shoji-backdrop-opacity: 1;
44
+
45
+ --shoji-focus-ring: 2px solid var(--shoji-color-accent);
46
+ --shoji-progress: var(--shoji-color-accent);
47
+
48
+ --shoji-spacing-sm: 0.5rem;
49
+ --shoji-spacing-md: 1rem;
50
+ --shoji-spacing-lg: 1.5rem;
51
+
52
+ --shoji-easing: cubic-bezier(0.4, 0, 0.2, 1);
53
+ --shoji-duration: 300ms;
54
+ /* The toolbar's own rendered height (44px button + top/bottom padding) —
55
+ defined before the caption sizing below since it now derives from
56
+ this. Also reserves space at the top of a provider embed (YouTube
57
+ etc.) so its own hover-revealed title bar never sits under Shoji's
58
+ toolbar; permanent there, not tied to the toolbar's auto-hide state,
59
+ so the video never resizes as the toolbar fades in/out. HTML5 video
60
+ has no top-of-player UI at all, so it's untouched by that use. */
61
+ --shoji-provider-video-top-inset: calc(44px + 2 * var(--shoji-spacing-sm));
62
+ /* DESIGN.md §2.4/§4.3 — a native <video controls>'s own scrub-bar/tap-
63
+ region is browser-rendered chrome, not real DOM Shoji can measure or
64
+ exclude by selector; approximated as a fixed bottom margin instead,
65
+ read by GestureController.ts's isInVideoControlsMargin(). 56px covers
66
+ the tallest common native controls bar (mobile Safari) with a little
67
+ headroom; a host that knows its target browsers render a shorter one
68
+ can safely set this lower. Swipe-to-navigate/drag-to-close only ever
69
+ reach the video body above this margin — never guaranteed to line up
70
+ with the real control bar pixel-for-pixel, just a reasonable default. */
71
+ --shoji-video-gesture-margin: 56px;
72
+ /* Caps how wide the caption box can grow before wrapping — `width:
73
+ fit-content` alone let a long caption wrap out to the dialog's full
74
+ width (only actually bounded by the padding subtracted below),
75
+ reading as a bar spanning the screen rather than a caption sized to
76
+ its own content. Desktop-only (see the media query below) — on a
77
+ narrow/mobile viewport the caption still spans (almost) the full
78
+ width, unchanged from before. */
79
+ --shoji-caption-max-width: calc(100% - 2 * var(--shoji-spacing-md));
80
+ /* DESIGN.md §2.3a — the collapsed default height, applied on top of
81
+ --shoji-caption-max-height (which only ever guards against reaching
82
+ the toolbar above, not the vertically-centered nav arrows sharing the
83
+ caption's own left edge). Requested directly, after a first version
84
+ that always collapsed to one line regardless of how much real room
85
+ existed: grow as tall as it can while staying clear of the nav
86
+ arrow's own footprint — `50%` (the dialog's own height, the
87
+ containing block `.shoji-caption`'s percentages resolve against) is
88
+ exactly where `.shoji-nav`'s own `top: 50%` sits, so `50%` minus half
89
+ its 44px height minus the caption's own bottom inset minus a small
90
+ breathing gap is the real ceiling, not an arbitrary line count. Pure
91
+ geometry — the arrow is always dead-center regardless of viewport, no
92
+ measurement needed the way the toolbar's own wrapping needs
93
+ `--shoji-toolbar-height` (Gallery.ts's ResizeObserver) to answer.
94
+ Floored (`max()`) at roughly one line + padding so a pathologically
95
+ short dialog can't collapse this to zero — the arrow may end up
96
+ slightly covered in that extreme case, but showing no caption at all
97
+ would be worse. A caption tall enough to still clip past all of this
98
+ gets `.shoji-caption--truncated` (Gallery.ts) and its own modal to
99
+ read the rest. */
100
+ --shoji-caption-collapsed-max-height: max(
101
+ calc(50% - 22px - var(--shoji-spacing-md) - var(--shoji-spacing-sm)),
102
+ calc(1.6em + 2 * var(--shoji-spacing-sm))
103
+ );
104
+ /* DESIGN.md §2.4 — the gesture engine's own documented default easing for
105
+ drag-to-navigate/drag-to-close settle animations specifically (a snap
106
+ tuned for "released mid-motion," distinct from --shoji-easing's
107
+ open/close/zoom-transition curve). */
108
+ --shoji-momentum-easing: cubic-bezier(0.2, 0, 0, 1);
109
+
110
+ /* System-font stack: no webfont download, renders instantly, looks native
111
+ (San Francisco / Segoe UI / Roboto) on every platform. */
112
+ --shoji-font-family:
113
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
114
+ 'Apple Color Emoji', 'Segoe UI Emoji';
115
+ }
116
+
117
+ .shoji-light {
118
+ --shoji-color-bg: #fafafa;
119
+ --shoji-color-fg: #111111;
120
+ --shoji-color-backdrop: #ffffff;
121
+ --shoji-color-toolbar-bg: rgba(255, 255, 255, 0.9);
122
+ --shoji-color-disabled: rgba(17, 17, 17, 0.35);
123
+ }
124
+
125
+ @media (prefers-reduced-motion: reduce) {
126
+ :root {
127
+ --shoji-duration: 0ms;
128
+ }
129
+ }
130
+
131
+ /* Narrows the caption from its mobile default (spans ~the full dialog
132
+ width) down to roughly a quarter of the viewport, floored at 14rem so
133
+ it stays legible in a smaller desktop window — reported from real
134
+ usage as an aesthetic complaint (a long caption reading as a bar
135
+ spanning the screen), desktop-only by request: a narrow/mobile
136
+ viewport keeps the original near-full-width behavior unchanged, since
137
+ there's much less width to spare there in the first place. 768px is an
138
+ ordinary phone/tablet breakpoint, not tied to any other Shoji default —
139
+ there's no matching JS-side concept of "mobile" this needs to stay
140
+ consistent with. */
141
+ @media (min-width: 768px) {
142
+ :root {
143
+ --shoji-caption-max-width: min(calc(100% - 2 * var(--shoji-spacing-md)), max(24vw, 14rem));
144
+ }
145
+ }
146
+
147
+ /* Lightbox structure (DESIGN.md §2.3 slides, §2.6 dialog). Built lazily on first open(). */
148
+ .shoji-outer {
149
+ display: none;
150
+ position: fixed;
151
+ inset: 0;
152
+ z-index: var(--shoji-z-base);
153
+ }
154
+
155
+ .shoji-outer.shoji-open {
156
+ display: block;
157
+ }
158
+
159
+ .shoji-backdrop {
160
+ position: absolute;
161
+ inset: 0;
162
+ z-index: var(--shoji-z-backdrop);
163
+ background: var(--shoji-color-backdrop);
164
+ opacity: var(--shoji-backdrop-opacity);
165
+ /* A real bug: without this, the backdrop had no transition of its own at
166
+ all — .shoji-outer (backdrop included) goes display:none in one
167
+ instant step once the zoom-out settles, so the still-fully-dark
168
+ backdrop behind the shrinking photo would snap straight to fully
169
+ visible-page-behind-it in a single frame, reading as a flash right as
170
+ the close finishes. Gallery.ts's beginClose() now starts this fade at
171
+ the same moment as the zoom-out and the controls fade, so all three
172
+ finish together instead of the backdrop cutting out abruptly at the end. */
173
+ transition: opacity var(--shoji-duration) var(--shoji-easing);
174
+ }
175
+
176
+ .shoji-dialog {
177
+ position: relative;
178
+ z-index: var(--shoji-z-slides);
179
+ display: flex;
180
+ flex-direction: column;
181
+ height: 100%;
182
+ color: var(--shoji-color-fg);
183
+ font-family: var(--shoji-font-family);
184
+ outline: none;
185
+ /* DESIGN.md §2.4 — the gesture engine owns horizontal/vertical drag on
186
+ this element; without this, touch browsers intercept the same gesture
187
+ for native scroll/pull-to-refresh/back-navigation before JS ever sees
188
+ enough movement to lock a direction. Buttons/video inside remain
189
+ interactive regardless (this is about implicit browser gesture
190
+ handling, not pointer-events). */
191
+ touch-action: none;
192
+ /* DESIGN.md §2.4 — a real bug, reported from real usage: a drag over the
193
+ photo/video (navigate/close) also started the browser's own native
194
+ text/image selection — visible as the image or video highlighting blue
195
+ mid-drag. `GestureEngine.ts` only calls `preventDefault()` on
196
+ `pointermove` once a direction has locked past `lockThreshold`
197
+ (horizontal) or not at all (vertical, DESIGN.md's own gesture-engine
198
+ notes above) — by then, or for a vertical drag, ever, the browser's own
199
+ selection-start heuristic has already had its chance. `user-select:
200
+ none` stops it at the source instead of racing preventDefault's own
201
+ timing. `.shoji-caption` below re-enables it — selecting caption text
202
+ is a deliberate, still-supported gesture-exclusion (this section,
203
+ "ignore()"), not something this should also block. */
204
+ user-select: none;
205
+ /* Caps how tall the caption box (a descendant, DESIGN.md §2.3a) can grow
206
+ before it scrolls internally — grows naturally with its content up to
207
+ that point, no cap kicking in early. The real ceiling is this
208
+ element's own height (100%) minus the toolbar's footprint at the top
209
+ minus the caption's own `bottom` offset minus a small breathing gap —
210
+ i.e. "as tall as it can get without reaching up under the toolbar,"
211
+ not an arbitrary fixed figure. Declared *here*, not `:root`, on
212
+ purpose — a real bug caught testing this: a custom property's nested
213
+ `var()` references substitute at the element where that property is
214
+ declared, not where a descendant later uses it, so defining this same
215
+ formula at `:root` baked in whatever `--shoji-toolbar-height`
216
+ happened to resolve to *there* (never set, always the fallback)
217
+ regardless of the real, per-instance value Gallery.ts's
218
+ ResizeObserver sets right on this same `.shoji-dialog` element below.
219
+ Declaring it here instead means the substitution happens where that
220
+ inline-set value is actually visible. */
221
+ --shoji-caption-max-height: calc(
222
+ 100% - var(--shoji-toolbar-height, var(--shoji-provider-video-top-inset)) -
223
+ var(--shoji-spacing-md) - var(--shoji-spacing-sm)
224
+ );
225
+ }
226
+
227
+ .shoji-toolbar {
228
+ position: absolute;
229
+ inset: 0 0 auto 0;
230
+ z-index: var(--shoji-z-toolbar);
231
+ display: flex;
232
+ justify-content: space-between;
233
+ padding: var(--shoji-spacing-sm);
234
+ pointer-events: none;
235
+ /* Reported from real usage: the Zoom plugin's frequent scale() updates on
236
+ the photo (a sibling, not a descendant) could show as GPU tile-seam
237
+ artifacts (thin lines) through the toolbar's own semi-transparent,
238
+ border-radius buttons — the same class of rendering quirk already noted
239
+ for progressive image decode. Promoting the toolbar to its own stable
240
+ compositing layer, decoupled from the photo's repaints, avoids it. */
241
+ will-change: transform;
242
+ }
243
+
244
+ /* DESIGN.md §3 — ctx.ui.toolbar(slot, ...) inserts plugin buttons into these;
245
+ pointer-events re-enabled per-slot (not per-button) so gaps between slots
246
+ stay click-through even once a slot has content. `flex-wrap: wrap` (not
247
+ `overflow-x: auto`) is deliberate: several plugins loaded together on a
248
+ narrow/mobile viewport can add up to more 44px buttons than the width
249
+ allows (CLAUDE.md's own touch-target size, non-negotiable), and this
250
+ project treats horizontal scroll as something to design around, not
251
+ reach for (see the layout plugin's masonry `orientation: 'horizontal'`,
252
+ which was rebuilt specifically to avoid it) — wrapping to a second row
253
+ keeps every button reachable by a tap with no sideways scroll gesture
254
+ anywhere in the lightbox chrome either.
255
+
256
+ max-width was 45% until a real bug, reported from real usage: the
257
+ toolbar-overflow popover (DESIGN.md §3.1a) collapses plugin buttons down
258
+ to `minPinnedToolbarButtons` (default 2), but 2 pinned + close + the
259
+ caret itself (4 icons, ~200px at the 44px touch-target size plus gaps)
260
+ already exceeds 45% of a common ~380px mobile width — so this slot fell
261
+ back to wrapping to a second row (pushing close onto its own line) even
262
+ though the *whole point* of the popover is that a bounded, small icon
263
+ count should now reliably fit in one row. Raised to 60% — comfortably
264
+ covers the default floor plus close/caret at typical mobile widths,
265
+ confirmed directly — while still leaving `.shoji-toolbar-left`'s own
266
+ counter room; a host who raises `minPinnedToolbarButtons` well past the
267
+ default on a narrow viewport can still hit this same wrap, by design
268
+ (DESIGN.md §3.1a: never collapsing below that floor, even if it still
269
+ leaves a slot wrapped, is the documented tradeoff). */
270
+ .shoji-toolbar-slot {
271
+ display: flex;
272
+ flex-wrap: wrap;
273
+ align-items: center;
274
+ gap: var(--shoji-spacing-sm);
275
+ pointer-events: auto;
276
+ }
277
+
278
+ .shoji-toolbar-left {
279
+ justify-content: flex-start;
280
+ }
281
+
282
+ .shoji-toolbar-right {
283
+ justify-content: flex-end;
284
+ }
285
+
286
+ .shoji-close,
287
+ .shoji-nav,
288
+ .shoji-toolbar-button,
289
+ .shoji-caption-modal-close {
290
+ appearance: none;
291
+ border: none;
292
+ padding: 0;
293
+ width: 44px;
294
+ height: 44px;
295
+ border-radius: 50%;
296
+ display: flex;
297
+ align-items: center;
298
+ justify-content: center;
299
+ background: rgba(0, 0, 0, 0.4);
300
+ color: var(--shoji-color-fg);
301
+ cursor: pointer;
302
+ }
303
+
304
+ .shoji-close:hover,
305
+ .shoji-nav:hover,
306
+ .shoji-toolbar-button:hover,
307
+ .shoji-caption-modal-close:hover {
308
+ background: rgba(0, 0, 0, 0.6);
309
+ }
310
+
311
+ .shoji-close:focus-visible,
312
+ .shoji-nav:focus-visible,
313
+ .shoji-toolbar-button:focus-visible,
314
+ .shoji-caption-modal-close:focus-visible {
315
+ outline: var(--shoji-focus-ring);
316
+ outline-offset: 2px;
317
+ }
318
+
319
+ .shoji-nav {
320
+ position: absolute;
321
+ top: 50%;
322
+ transform: translateY(-50%);
323
+ z-index: var(--shoji-z-toolbar);
324
+ }
325
+
326
+ .shoji-nav-prev {
327
+ left: var(--shoji-spacing-md);
328
+ }
329
+
330
+ .shoji-nav-next {
331
+ right: var(--shoji-spacing-md);
332
+ }
333
+
334
+ .shoji-nav:disabled {
335
+ opacity: 0.3;
336
+ cursor: default;
337
+ pointer-events: none;
338
+ }
339
+
340
+ .shoji-close[hidden],
341
+ .shoji-nav[hidden],
342
+ .shoji-toolbar-button[hidden] {
343
+ display: none;
344
+ }
345
+
346
+ /* DESIGN.md §2.3 — while the active slide's own content is still loading,
347
+ feature/plugin toolbar buttons (zoom, rotate, fullscreen, autoplay, any
348
+ custom plugin's own button) dim and stop responding, since they'd
349
+ otherwise act on content about to be replaced. Deliberately scoped to
350
+ `.shoji-toolbar-button` only — `.shoji-close`/`.shoji-nav` are different
351
+ classes, so closing and navigating are never blocked by a slow image.
352
+ `pointer-events: none` here is the mouse half; Gallery.ts's matching
353
+ `tabIndex = -1` is the keyboard half (this alone wouldn't stop
354
+ Enter/Space activating an already-focused button). */
355
+ .shoji-outer.shoji-slide-loading .shoji-toolbar-button {
356
+ opacity: 0.3;
357
+ cursor: default;
358
+ pointer-events: none;
359
+ }
360
+
361
+ .shoji-slides {
362
+ position: relative;
363
+ flex: 1;
364
+ overflow: hidden;
365
+ }
366
+
367
+ .shoji-slide {
368
+ position: absolute;
369
+ inset: 0;
370
+ display: flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ /* transform (translateX per pool offset) is set inline by SlideManager */
374
+ }
375
+
376
+ .shoji-slide-media {
377
+ /* Definite size (not max-width/max-height alone) so the img's own
378
+ percentage-based max-width/max-height below has something concrete to
379
+ resolve against — otherwise it sizes to the image's natural dimensions
380
+ instead of being contained, and object-fit: contain never engages. */
381
+ width: 100%;
382
+ height: 100%;
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ /* Positioning context for this box's own absolutely-positioned children
387
+ (the spinner, the open placeholder) specifically, not implicitly
388
+ whichever positioned ancestor happens to be next up (currently
389
+ .shoji-slide, coincidentally the same size — explicit here so that
390
+ stays true even if that ever changes). */
391
+ position: relative;
392
+ /* DESIGN.md §4-video — a provider video's real shape (YouTube/Vimeo/
393
+ html5), as a plain number SlideManager sets from item.width/item.height
394
+ (`applyAspect()`); `calc(16 / 9)` here is only the fallback for an item
395
+ that never declares a size, kept exact via calc() rather than a rounded
396
+ decimal. `.shoji-slide-provider-poster` below reads this directly.
397
+ `.shoji-slide-provider-video > iframe`/`.shoji-video-mount` also read it,
398
+ through their own nearer `container-type: size` context (still on
399
+ `.shoji-slide-provider-video` itself, unchanged) — declared again here
400
+ too only so a poster, `.shoji-slide-provider-video`'s own *sibling*
401
+ rather than a descendant of it, has a container-type ancestor to
402
+ resolve cqw/cqh against at all. */
403
+ --shoji-provider-video-aspect: calc(16 / 9);
404
+ container-type: size;
405
+ }
406
+
407
+ .shoji-slide-img,
408
+ .shoji-slide-video {
409
+ display: block;
410
+ max-width: 100%;
411
+ max-height: 100%;
412
+ object-fit: contain;
413
+ }
414
+
415
+ /* max-width/max-height above only ever *cap* size — a real photo is normally
416
+ bigger than the slide area, so that's correct. A low-res open() placeholder
417
+ (§2.3) is the opposite case: deliberately small, so it needs to be forced
418
+ up to fill the frame (blurry, on purpose) instead of rendering at its own
419
+ tiny natural size. object-fit: contain (inherited above) still letterboxes
420
+ it within that forced box, just per the thumbnail's own aspect ratio. */
421
+ .shoji-slide-open-placeholder {
422
+ width: 100%;
423
+ height: 100%;
424
+ }
425
+
426
+ /* A provider video's poster (§4.3, `item.poster`/a renderer's own fallback)
427
+ is the same "usually a small thumbnail file, needs to be forced up to
428
+ fill the frame" case as the open placeholder above — a real bug, a small
429
+ poster rendering as a postage-stamp-sized image floating in the slide
430
+ instead of filling it. Sized to the *video's* real shape
431
+ (`--shoji-provider-video-aspect`, `.shoji-slide-media` above) via the same
432
+ cqw/cqh contain-within-either-axis formula the real embed below uses, not
433
+ plain width/height: 100% — so the poster occupies exactly the box the
434
+ embed will occupy once it swaps in, instead of the two visibly jumping
435
+ between a video-shaped box and a dialog-shaped one. */
436
+ .shoji-slide-provider-poster {
437
+ width: min(100cqw, calc(100cqh * var(--shoji-provider-video-aspect)));
438
+ height: min(100cqh, calc(100cqw / var(--shoji-provider-video-aspect)));
439
+ }
440
+
441
+ /* DESIGN.md §4-video — a provider's embed (an <iframe>, typically) has no
442
+ natural size Shoji can measure the way a decoded image/video does. A real
443
+ bug, reported from real usage, found in two parts:
444
+ 1. Stretching the embed to fill this box regardless of the video's own
445
+ aspect ratio pushed the bottom of the video (and a provider's own
446
+ controls, always along that edge) below the visible area whenever the
447
+ box wasn't already the same shape as the video.
448
+ 2. Fixing that by fitting to the box's actual shape instead exposed a
449
+ second issue: neither YouTube's nor Vimeo's own player page renders
450
+ with a background matching Shoji's theme in the letterboxed gap
451
+ *inside* the iframe (both showed white) — a color this page has no
452
+ way to reach or override, since that content is cross-origin.
453
+ Assumes 16:9 (overwhelmingly the common case for both providers, and
454
+ exactly correct for the video this was diagnosed against) and *contains*
455
+ within whatever box is actually available — the same shape object-fit
456
+ already gives an <img>/<video> above, achieved differently here since
457
+ object-fit doesn't apply to an iframe (or the wrapper div Vimeo's own
458
+ SDK builds one inside) — so any remaining gap is outside the iframe
459
+ instead of inside it, showing Shoji's own dark backdrop through this
460
+ element's own transparent background rather than the provider's white. */
461
+ .shoji-slide-provider-video {
462
+ /* `position: absolute; inset: 0` instead of sizing this as a centered flex
463
+ item: a real bug, reported from real usage, traced to this box's parent
464
+ (`.shoji-slide-media`, `justify-content: center`) miscentering it once
465
+ `container-type: size` (below) is also in play — confirmed directly via
466
+ `getBoundingClientRect()` on the real reported case, rendering the box
467
+ shifted half its own width off-screen. Taking it out of flex flow
468
+ entirely sidesteps the centering pass for this element altogether: it
469
+ fills `.shoji-slide-media` directly (already `position: relative`), no
470
+ distribution/centering math involved. `.shoji-slide-media`'s own
471
+ `justify-content: center` is untouched — still needed for photo/HTML5-
472
+ video slides, where content can be smaller than the box. */
473
+ position: absolute;
474
+ inset: 0;
475
+ display: flex;
476
+ align-items: center;
477
+ justify-content: center;
478
+ box-sizing: border-box;
479
+ padding-top: var(--shoji-provider-video-top-inset);
480
+ container-type: size;
481
+ }
482
+
483
+ .shoji-slide-provider-video[hidden] {
484
+ display: none;
485
+ }
486
+
487
+ /* `.shoji-video-mount` — src/plugins/video/vimeo.ts's own wrapper div,
488
+ which its SDK would otherwise size itself (a small fixed default
489
+ normally, or its own `responsive: true` option's aspect-ratio-from-width-
490
+ alone computation, ignoring how much height is actually available either
491
+ way) — `!important` discards whatever it tries to set inline. The direct
492
+ `> iframe` case is YouTube, which has no such wrapper at all.
493
+ `min(100cqw, 100cqh * ratio)`/`min(100cqh, 100cqw / ratio)` is the
494
+ contain-within-either-axis formula `object-fit: contain` gives an
495
+ <img>/<video> natively — plain `aspect-ratio` alone can't do this for a
496
+ non-replaced element like a div/iframe: it only ever derives *one*
497
+ dimension from the other, so whichever of width/height stays a fixed
498
+ `100%` never actually shrinks even once the derived dimension gets
499
+ capped by `max-width`/`max-height`, silently distorting the ratio in
500
+ whichever direction wasn't the binding constraint. Confirmed directly at
501
+ both a wide-short viewport (height the binding constraint) and a
502
+ narrow-tall one (width the binding constraint) — container query units
503
+ are what actually solve both at once, not `aspect-ratio` by itself.
504
+ `--shoji-provider-video-aspect` (`.shoji-slide-media` above) is the
505
+ item's own real `width`/`height` ratio when known — a real bug/gap this
506
+ replaces a hardcoded `16/9` to fix: every provider video rendered as if
507
+ it were 16:9 regardless of its declared shape, YouTube/Vimeo/html5 alike
508
+ (html5's own `<video>` element is fixed the same way, directly, in
509
+ `SlideManager.ts`'s `renderVideo()` — it doesn't go through this
510
+ container-query-based sizing at all, having a real intrinsic size of its
511
+ own once it loads). */
512
+ .shoji-slide-provider-video > iframe,
513
+ .shoji-video-mount {
514
+ width: min(100cqw, calc(100cqh * var(--shoji-provider-video-aspect))) !important;
515
+ height: min(100cqh, calc(100cqw / var(--shoji-provider-video-aspect))) !important;
516
+ margin: 0 !important;
517
+ padding: 0 !important;
518
+ }
519
+
520
+ .shoji-slide-provider-video iframe {
521
+ display: block;
522
+ border: 0;
523
+ }
524
+
525
+ /* Once `.shoji-video-mount` above is already correctly contain-fitted, its
526
+ own iframe just needs to fill it completely — no `aspect-ratio` of its
527
+ own to fight the wrapper's. Same `!important`/static-not-absolute
528
+ reasoning as `.shoji-video-mount` — Vimeo's SDK may set its own inline
529
+ sizing/position on the iframe too, not just on the wrapper. */
530
+ .shoji-video-mount iframe {
531
+ position: static !important;
532
+ width: 100% !important;
533
+ height: 100% !important;
534
+ }
535
+
536
+ /* DESIGN.md §2.5 — the temporary clone SlideTransition animates the
537
+ outgoing slide's content out on. Sits above the normal (unpositioned
538
+ z-index) pool slots by DOM order alone, but z-index is set explicitly
539
+ here rather than relied on implicitly, matching how every other overlay
540
+ layer in this file states its stacking intent up front. Not interactive
541
+ (it's a decorative clone mid-removal) and never receives its own size —
542
+ it fills `.shoji-slides` exactly like the real `.shoji-slide` it's a
543
+ snapshot of, so the cloned `.shoji-slide-media` inside it (already
544
+ `width/height: 100%`) lines up pixel-for-pixel with no extra CSS needed. */
545
+ .shoji-slide-ghost {
546
+ position: absolute;
547
+ inset: 0;
548
+ z-index: 1;
549
+ pointer-events: none;
550
+ }
551
+
552
+ .shoji-slide-placeholder {
553
+ color: var(--shoji-color-disabled);
554
+ }
555
+
556
+ /* DESIGN.md §2.3 — shown in place of a slide's content while it's still
557
+ decoding/loading (outside the `preload` window, where nothing was ready
558
+ ahead of time). CSS-only rotation (no JS-driven layout, per CLAUDE.md);
559
+ disabled under prefers-reduced-motion the same as every other animation
560
+ here, rather than left spinning regardless. */
561
+ .shoji-slide-spinner {
562
+ width: var(--shoji-spinner-size, 40px);
563
+ height: var(--shoji-spinner-size, 40px);
564
+ border-radius: 50%;
565
+ border: var(--shoji-spinner-thickness, 3px) solid var(--shoji-color-disabled);
566
+ border-top-color: var(--shoji-color-accent);
567
+ animation: shoji-spin 0.8s linear infinite;
568
+ }
569
+
570
+ @keyframes shoji-spin {
571
+ to {
572
+ transform: rotate(360deg);
573
+ }
574
+ }
575
+
576
+ @media (prefers-reduced-motion: reduce) {
577
+ .shoji-slide-spinner {
578
+ animation: none;
579
+ }
580
+ }
581
+
582
+ /* A normal flex item within .shoji-toolbar-left (not independently
583
+ positioned) — .shoji-slides still gets the full dialog height either way,
584
+ no reserved gutter at top or bottom, since the toolbar itself overlays the
585
+ slides rather than pushing them down. Flowing alongside whatever else is
586
+ in that slot (e.g. a plugin's ctx.ui.toolbar('left', ...) button) keeps it
587
+ from overlapping and stealing clicks the way independent absolute
588
+ positioning did. */
589
+ .shoji-counter {
590
+ padding: 0.25rem 0.6rem;
591
+ border-radius: 999px;
592
+ background: rgba(0, 0, 0, 0.4);
593
+ font-size: 0.875rem;
594
+ white-space: nowrap;
595
+ }
596
+
597
+ .shoji-counter[hidden] {
598
+ display: none;
599
+ }
600
+
601
+ .shoji-caption {
602
+ position: absolute;
603
+ bottom: var(--shoji-spacing-md);
604
+ left: var(--shoji-spacing-md);
605
+ z-index: var(--shoji-z-toolbar);
606
+ /* Re-enables text selection `.shoji-dialog`'s own user-select: none
607
+ (above) turns off broadly — caption text staying selectable is a
608
+ deliberate, existing feature (this section's "ignore()"), unaffected
609
+ by that fix for the photo/video's own native-selection bug. */
610
+ user-select: text;
611
+ width: fit-content;
612
+ max-width: var(--shoji-caption-max-width);
613
+ max-height: min(var(--shoji-caption-max-height), var(--shoji-caption-collapsed-max-height));
614
+ /* DESIGN.md §2.3a — a clean clip, not a scrollbar: the collapsed cap is
615
+ now small (~1 line) by design, so almost any real caption exceeds it;
616
+ a visible scrollbar on that tiny a box would look broken. Reading the
617
+ rest is what `.shoji-caption-modal` (Gallery.ts) is for instead — its
618
+ own content area is where `overflow-y: auto` still applies. */
619
+ overflow: hidden;
620
+ padding: var(--shoji-spacing-sm) var(--shoji-spacing-md);
621
+ /* A real bug, caught by an e2e regression test: without this, max-height
622
+ only constrains the content box — the element's own padding (16px,
623
+ top+bottom) then renders on top of that, eating into the breathing
624
+ gap the height cap's own calc (--shoji-caption-max-height, above)
625
+ already budgeted for, enough to actually overlap the toolbar instead
626
+ of stopping just short of it. */
627
+ box-sizing: border-box;
628
+ border-radius: 8px;
629
+ background: var(--shoji-color-toolbar-bg);
630
+ text-align: left;
631
+ /* A real bug, caught testing the truncated-caption line-clamp
632
+ (DESIGN.md §2.3a): left unset, `line-height` computes to the literal
633
+ keyword "normal" rather than a resolved px value, so
634
+ `updateCaptionTruncation()`'s own line-height measurement (Gallery.ts)
635
+ could only ever approximate it — close enough to be off by a
636
+ fractional line, letting one extra partial line bleed in below the
637
+ clamp's own ellipsis instead of cutting cleanly. An explicit value
638
+ here makes that measurement exact instead of estimated. */
639
+ line-height: 1.4;
640
+ }
641
+
642
+ /* A real bug: height-capping the caption (above) still isn't enough on its
643
+ own — a video that fills most of the dialog leaves little to no
644
+ letterboxing gap, so even a short caption's opaque background can land
645
+ directly on the native control bar underneath it (same corner). Letting
646
+ clicks pass straight through the caption on a video slide (Gallery.ts
647
+ toggles this class from `item.video`, HTML5 or a provider like YouTube
648
+ alike) is what actually guarantees the controls stay reachable, not just
649
+ "usually clear of them." Re-enabled on any real child element so a rich
650
+ HTML caption's own links/buttons (DESIGN.md §2.1) stay clickable — only
651
+ a plain-string caption (no element children at all) ever fully passes
652
+ through. Photo slides are untouched: nothing below them needs protecting,
653
+ so the caption keeps its normal scrollable/selectable behavior there. */
654
+ .shoji-caption--video {
655
+ pointer-events: none;
656
+ }
657
+
658
+ .shoji-caption--video > * {
659
+ pointer-events: auto;
660
+ }
661
+
662
+ /* DESIGN.md §2.3a — only ever added once content genuinely overflows the
663
+ collapsed clip (Gallery.ts measures scrollHeight vs. clientHeight), so a
664
+ caption that already fits never looks or behaves clickable. On a video
665
+ slide `.shoji-caption--video`'s own pointer-events: none (above) already
666
+ makes this a no-op for pointer input there, on purpose — reaching the
667
+ native video controls underneath still wins; a keyboard user can still
668
+ Tab to it and open the modal regardless, since that's not competing for
669
+ the same physical space a click would be. */
670
+ .shoji-caption--truncated {
671
+ cursor: pointer;
672
+ }
673
+
674
+ /* A visible cue, not just a silent clip. `-webkit-line-clamp` (the
675
+ standard tool for this) was tried first but didn't reliably stop at a
676
+ clean line boundary here — regardless of how precisely the line count
677
+ fed into it was computed, a partial extra line kept bleeding through
678
+ below its own ellipsis. Gallery.ts's updateCaptionTruncation() instead
679
+ snaps `max-height` itself down to an exact whole-line multiple, so a
680
+ plain clip can never land mid-line — this "…" chip is what actually
681
+ supplies the visible cue for that, pinned over the last visible line's
682
+ corner, background-matched so it masks whatever text it sits on. */
683
+ .shoji-caption--truncated::after {
684
+ content: '…';
685
+ position: absolute;
686
+ right: var(--shoji-spacing-sm);
687
+ bottom: var(--shoji-spacing-sm);
688
+ padding-left: 0.5em;
689
+ background: var(--shoji-color-toolbar-bg);
690
+ }
691
+
692
+ /* DESIGN.md §2.3a — the truncated caption's own expanded view. A real
693
+ backdrop over the whole dialog, not an in-place taller caption: growing
694
+ the caption itself tall enough to show everything would risk covering
695
+ the nav arrows again, exactly the bug this feature exists to avoid. */
696
+ .shoji-caption-modal {
697
+ position: absolute;
698
+ inset: 0;
699
+ z-index: var(--shoji-z-caption-modal);
700
+ display: flex;
701
+ align-items: center;
702
+ justify-content: center;
703
+ padding: var(--shoji-spacing-lg);
704
+ background: rgba(0, 0, 0, 0.6);
705
+ }
706
+
707
+ .shoji-caption-modal[hidden] {
708
+ display: none;
709
+ }
710
+
711
+ .shoji-caption-modal-panel {
712
+ position: relative;
713
+ max-width: 32rem;
714
+ max-height: 100%;
715
+ overflow-y: auto;
716
+ overscroll-behavior: contain;
717
+ padding: var(--shoji-spacing-lg);
718
+ padding-inline-end: calc(44px + var(--shoji-spacing-lg));
719
+ border-radius: 8px;
720
+ background: var(--shoji-color-bg);
721
+ color: var(--shoji-color-fg);
722
+ }
723
+
724
+ .shoji-caption-modal-close {
725
+ position: absolute;
726
+ top: var(--shoji-spacing-sm);
727
+ right: var(--shoji-spacing-sm);
728
+ }
729
+
730
+ .shoji-caption[hidden] {
731
+ display: none;
732
+ }
733
+
734
+ /* DESIGN.md §3.1a — hidden unless the toolbar actually overflows
735
+ (Gallery.ts's measureToolbarOverflow() reveals it), so it never adds a
736
+ permanent extra control to a toolbar that never needed one. */
737
+ .shoji-toolbar-overflow[hidden] {
738
+ display: none;
739
+ }
740
+
741
+ /* 3 columns, wrapping to as many rows as needed — requested directly.
742
+ Width is content-driven (3 * 44px + 2 gaps + padding), not clamped to a
743
+ fraction of the dialog, since it never grows past that regardless of how
744
+ many buttons collapse into it. Columns are 44px with `--shoji-spacing-sm`
745
+ gaps — deliberately the same pitch as a toolbar button's own size and
746
+ `.shoji-toolbar-slot`'s own gap, so the popover's icon columns actually
747
+ line up with the toolbar row's icons above them.
748
+
749
+ `right` here is only a pre-open fallback — Gallery.ts's
750
+ `positionToolbarOverflowPanel()` sets it inline every time the panel
751
+ opens, anchored to the caret's own live position (which shifts with
752
+ `minPinnedToolbarButtons` and viewport width) rather than this fixed
753
+ offset from the dialog's edge, which otherwise lined up under
754
+ `closeButton` instead of the caret that actually opens it. */
755
+ .shoji-toolbar-overflow-panel {
756
+ position: absolute;
757
+ top: var(--shoji-toolbar-height, 56px);
758
+ right: var(--shoji-spacing-md);
759
+ z-index: var(--shoji-z-toast);
760
+ display: grid;
761
+ grid-template-columns: repeat(3, 44px);
762
+ gap: var(--shoji-spacing-sm);
763
+ max-height: calc(100% - var(--shoji-toolbar-height, 56px) - var(--shoji-spacing-md));
764
+ overflow-y: auto;
765
+ padding: var(--shoji-spacing-sm);
766
+ border-radius: 8px;
767
+ background: var(--shoji-color-bg);
768
+ color: var(--shoji-color-fg);
769
+ }
770
+
771
+ .shoji-toolbar-overflow-panel[hidden] {
772
+ display: none;
773
+ }
774
+
775
+ /* DESIGN.md §2.8 — idle auto-hide. Opacity only, never display:none, never
776
+ removed from the tab order: a hidden-but-focused control stays interactive,
777
+ just visually faded (see Gallery's isControlFocused() guard). */
778
+ .shoji-toolbar,
779
+ .shoji-nav,
780
+ .shoji-counter,
781
+ .shoji-caption {
782
+ transition: opacity var(--shoji-duration) var(--shoji-easing);
783
+ }
784
+
785
+ .shoji-controls-hidden .shoji-toolbar,
786
+ .shoji-controls-hidden .shoji-nav,
787
+ .shoji-controls-hidden .shoji-counter,
788
+ .shoji-controls-hidden .shoji-caption {
789
+ opacity: 0;
790
+ pointer-events: none;
791
+ }
792
+
793
+ /* !important: wins over any plugin's own cursor styling (e.g. zoom.css's
794
+ zoom-in/grab affordances) without core needing to know that plugin's
795
+ selectors — the hidden state means nothing should suggest interactivity. */
796
+ .shoji-controls-hidden,
797
+ .shoji-controls-hidden * {
798
+ cursor: none !important;
799
+ }
800
+
801
+ /* autoHideDelay: 0 (Gallery.ts) — Shoji's own controls stay permanently
802
+ invisible, but the cursor itself should behave normally, not force-hidden
803
+ along with them. Higher specificity than the rule above (both compound
804
+ selectors carry two classes/one-class-plus-descendant vs. one), so this
805
+ wins despite both using !important. */
806
+ .shoji-cursor-visible.shoji-controls-hidden,
807
+ .shoji-cursor-visible.shoji-controls-hidden * {
808
+ cursor: auto !important;
809
+ }
810
+
811
+ /* A real bug, reported from real usage: dragging past the close threshold
812
+ (GestureController's live controls-hide cue, §2.4/§2.8) reuses this same
813
+ `.shoji-controls-hidden` class, so the cursor vanished along with the
814
+ toolbar/nav/counter/caption — disorienting mid-drag, when the pointer is
815
+ actively moving and the viewer needs to see it. `cursor: none` only makes
816
+ sense for an idle/inactive hide; a drag is the opposite of idle. Not
817
+ reusing `.shoji-cursor-visible` here — that class is a *permanent* marker
818
+ for `autoHideDelay: 0`, and toggling it on/off with the drag would
819
+ incorrectly clear it once the drag ends even when that permanent mode is
820
+ also active. */
821
+ .shoji-controls-hidden-for-drag.shoji-controls-hidden,
822
+ .shoji-controls-hidden-for-drag.shoji-controls-hidden * {
823
+ cursor: auto !important;
824
+ }
825
+
826
+ .shoji-live-region {
827
+ position: absolute;
828
+ width: 1px;
829
+ height: 1px;
830
+ overflow: hidden;
831
+ clip: rect(0 0 0 0);
832
+ white-space: nowrap;
833
+ }