@wyxos/vibe 5.17.0 → 5.17.2

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 DELETED
@@ -1,847 +0,0 @@
1
- # Vibe
2
-
3
- Vibe is an initializable Vue 3 media feed with virtualized masonry and reel layouts.
4
-
5
- Try the [live demo](https://vibe.wyxos.com/) to explore the feed and its controls.
6
- Read the [full documentation](./docs/index.md) for guided setup, layout and data-loading concepts, and the API reference.
7
-
8
- For an existing host-owned player and large queue, use the [host-controlled reel](./docs/reference/controlled-reel.md) with a bounded previous/current/next projection and stable media slot.
9
-
10
- [![Vibe demo: scroll a masonry feed, open a card in the reel, and press Escape to return to the same scroll position.](https://raw.githubusercontent.com/wyxos/vibe/master/docs/assets/vibe-demo.gif)](https://vibe.wyxos.com/)
11
-
12
- The preview uses synthetic sample media. Open a masonry card to view it in a reel,
13
- scroll to move between posts, and press Escape to return to the feed.
14
-
15
- ## Installation
16
-
17
- ```bash
18
- npm install @wyxos/vibe
19
- ```
20
-
21
- Import the packaged styles once in the consuming application:
22
-
23
- ```ts
24
- import '@wyxos/vibe/style.css'
25
- ```
26
-
27
- ## Remote feed
28
-
29
- ```ts
30
- import { createVibe } from '@wyxos/vibe'
31
-
32
- const vibe = createVibe({
33
- target: '#gallery',
34
- layout: 'responsive',
35
- loadPage: async ({ cursor, signal }) => {
36
- const response = await fetch(`/api/media?cursor=${cursor ?? ''}`, {
37
- signal,
38
- })
39
-
40
- return response.json()
41
- },
42
- })
43
-
44
- await vibe.mount()
45
- ```
46
-
47
- The target element must have a usable height. Vibe owns scrolling inside that target.
48
- In responsive layout, Vibe observes the target and uses reels on phones while
49
- keeping tablets and desktops in masonry. Use `masonry` or `reel` to force a
50
- renderer instead.
51
-
52
- Masonry loads `preview`. Reels on tablets and desktops load the canonical
53
- `src`, including a reel forced with `layout: 'reel'` and a reel opened from a
54
- masonry card. Phone reels load the optional `mobile` variant when supplied and
55
- otherwise fall back to the canonical `src`; feed previews are never promoted to
56
- reel playback sources.
57
-
58
- `loadPage` receives `cursor: null` for the initial request. It returns normalized Vibe items, the next opaque cursor, and an optional total:
59
-
60
- ```ts
61
- {
62
- items: VibeItem[]
63
- next: string | number | null
64
- total?: number
65
- }
66
- ```
67
-
68
- ## Static or preloaded feed
69
-
70
- ```ts
71
- const vibe = createVibe({
72
- target: document.querySelector('#gallery')!,
73
- layout: 'reel',
74
- initialPage: {
75
- items,
76
- next: null,
77
- total: items.length,
78
- },
79
- })
80
-
81
- await vibe.mount()
82
- ```
83
-
84
- Provide both `initialPage` and `loadPage` when preloaded items can continue to another cursor.
85
-
86
- ## Custom card chrome
87
-
88
- Use `cardHeader` and `cardFooter` to render application-owned regions above and
89
- below media:
90
-
91
- ```ts
92
- import MediaInfo from './MediaInfo.vue'
93
- import MediaReactions from './MediaReactions.vue'
94
-
95
- const vibe = createVibe({
96
- target: '#gallery',
97
- layout: 'responsive',
98
- loadPage,
99
- cardHeader: {
100
- background: 'transparent',
101
- component: MediaInfo,
102
- height: 40,
103
- },
104
- cardFooter: {
105
- background: 'transparent',
106
- component: MediaReactions,
107
- height: 48,
108
- },
109
- mediaCard: {
110
- feedPreload: 'visible-post',
111
- videoMuted: true,
112
- },
113
- })
114
- ```
115
-
116
- The optional `background` is either `default` (the existing Vibe surface) or
117
- `transparent`. The heights are CSS pixels and let Vibe include both regions in
118
- masonry before virtualized cards mount. In reel layout, one header and footer
119
- remain stationary while only the media viewport scrolls between posts. Each
120
- component controls its own content and alignment, and receives `item`, `layout`,
121
- `mediaSource`, zero-based `index`, `loadedCount`, the active `mediaItem`, its
122
- zero-based `mediaIndex`, its inclusive `mediaCount`, and the optional remote
123
- `total`. Import the `VibeCardRegionProps` type for typed Vue props. Interacting
124
- with these regions does not activate the underlying media.
125
-
126
- Masonry videos start muted, while the active reel video starts unmuted. Inactive
127
- reel videos are always paused and muted while they remain mounted for nearby
128
- virtualization; their user-selected mute state is restored when they become
129
- active again. Set `mediaCard.videoMuted` to explicitly override the initial
130
- state in either layout. Users can still change mute and volume through the
131
- timed-media controls.
132
-
133
- Set `mediaCard.feedPreload` to `'visible-post'` to warm every other preview in
134
- a grouped masonry card after its visible media is ready. Only cards in the
135
- exact viewport qualify: images are fetched at low priority and decoded, while
136
- video or audio sources load metadata without preloading their full bodies.
137
- The default is `'none'`, and reel loading is unchanged.
138
-
139
- Applications that need audio continuity can pass `initialReelAudioState` with
140
- `volume`, `muted`, and `lastAudibleVolume`, then persist user changes through
141
- `onReelAudioStateChange`. Every reel card in that Vibe instance shares the
142
- state. `getReelAudioState()` returns a snapshot and `setReelAudioState()` applies
143
- an external update without invoking the callback, which lets applications sync
144
- several Vibe instances without an echo loop. Masonry's forced mute behavior is
145
- unchanged.
146
-
147
- Masonry virtualization keeps the existing 800px/1.5-viewport overscan by
148
- default. Consumers can opt into a capped window while retaining every loaded
149
- item and the same removal, restoration, reel, and pagination behavior:
150
-
151
- ```ts
152
- masonry: {
153
- minColumnWidth: 400,
154
- overscan: {
155
- minimumPx: 600,
156
- viewportMultiplier: 0.5,
157
- maximumPx: 1_000,
158
- },
159
- },
160
- ```
161
-
162
- `minColumnWidth` defaults to `240` CSS pixels. Raising it produces fewer,
163
- larger masonry columns without constraining the feed container.
164
-
165
- Low-priority images in that overscan window use native lazy loading, and
166
- low-priority masonry videos defer metadata until they reach the real viewport.
167
- Reel preload behavior remains unchanged.
168
-
169
- When a feed item has entries in `item.items`, Vibe treats the parent media as
170
- position one and the nested entries as the remaining positions. Masonry cards
171
- show looping previous/next controls on hover or keyboard focus. Reel layout
172
- keeps those controls visible and supports native horizontal-wheel or touch-swipe
173
- navigation through grouped media. Media changes use a directional horizontal
174
- transition, while vertical reel scrolling continues to move between posts.
175
- Media assets may include a stable `mediaId` (`string | number`) when consumers
176
- need to select exact grouped media independently of its array position.
177
-
178
- ## Media lifecycle hooks
179
-
180
- Use `onMediaReady` when an image has loaded or a video has enough metadata for
181
- Vibe to render it. `onMediaVisible` reports each ready masonry media item once
182
- per feed visit when it first intersects the viewport. `onMediaFullyVisible`
183
- reports each ready media item once per layout and feed visit: after the masonry
184
- card reaches its full visibility threshold, or after the media is active and
185
- ready in a reel.
186
- `onReelMediaChange` reports reel selection changes:
187
-
188
- ```ts
189
- const vibe = createVibe({
190
- target: '#gallery',
191
- loadPage,
192
- onMediaReady: ({ postId, mediaId, layout, origin }) => {
193
- recordMediaReady({ postId, mediaId, layout, origin })
194
- },
195
- onMediaVisible: ({ postId, mediaIndex }) => {
196
- recordMediaPreviewed({ postId, mediaIndex })
197
- },
198
- onMediaFullyVisible: ({ postId, mediaIndex, layout }) => {
199
- recordMediaViewed({ postId, mediaIndex, layout })
200
- },
201
- onReelMediaChange: ({ postId, mediaId, postIndex, mediaIndex }) => {
202
- updateActiveMedia({ postId, mediaId, postIndex, mediaIndex })
203
- },
204
- })
205
- ```
206
-
207
- All lifecycle callbacks receive `VibeMediaLifecycleContext`: stable `postId`
208
- and `mediaId` identity, zero-based `postIndex` and `mediaIndex`, the complete
209
- `item` and selected `media`, the rendered `layout`, and `origin`. `mediaId` is
210
- `null` when the source asset does not define one. `origin` is `null` for the
211
- masonry grid, `reel` for the base reel, and `masonry` for a reel opened from a
212
- masonry card. The readiness hook may run again if media remounts and becomes
213
- ready again. A feed visit begins when Vibe mounts and restarts whenever
214
- `refresh()` or `reload()` replaces the feed. Visibility callbacks stay
215
- deduplicated while scrolling, loading more, or retrying pagination within that
216
- visit.
217
- Normal masonry cards require their complete height; cards taller than the
218
- viewport require 80% of their maximum attainable visible height. Reel fully
219
- visible events depend on active-and-ready state rather than intersection.
220
- Reel-change events describe selection changes, not load state.
221
-
222
- ## Reel URLs with Vue Router
223
-
224
- Pass a Vue Router instance when the application should reflect the active reel
225
- in its URL. The consumer owns the route names and the item-to-location mapping:
226
-
227
- ```ts
228
- import { useRouter } from 'vue-router'
229
- import { createVibe } from '@wyxos/vibe'
230
-
231
- const router = useRouter()
232
- const vibe = createVibe({
233
- target: '#gallery',
234
- loadPage,
235
- routing: {
236
- router,
237
- feed: { name: 'gallery' },
238
- reel: ({ item }) => ({
239
- name: 'gallery-file',
240
- params: { fileId: String(item.postId) },
241
- }),
242
- },
243
- })
244
- ```
245
-
246
- Vibe pushes the first reel location, replaces it as the active reel changes,
247
- and replaces it with `feed` when the masonry-origin reel closes or responsive
248
- layout returns to masonry. The reel callback also receives the zero-based
249
- `index`, `loadedCount`, optional remote `total`, and whether the reel originated
250
- from `masonry` or the base `reel` layout. Return `null` to skip a URL update for
251
- an item. Vue Router is an optional peer dependency and is only needed when this
252
- integration is used.
253
-
254
- ## Autofill
255
-
256
- Autofill targets a number of new top-level cards after the provider or consumer
257
- adapter has grouped each post into one `VibeItem`. Vibe does not perform
258
- provider-specific grouping; it only deduplicates `postId` defensively as pages
259
- are appended. `autofill.pageSize` is the minimum card target for one Vibe load
260
- cycle, not the server's own response size. For example, a server may return up
261
- to 30 grouped cards per request while Vibe is configured with `pageSize: 40`;
262
- Vibe then follows cursors until the cycle contains at least 40 cards.
263
-
264
- ### Frontend autofill
265
-
266
- Frontend autofill follows cursors and commits the collected batch once the
267
- target is reached or the source is exhausted. Restore committed items with
268
- `initialPage`; its `next` value must follow the last restored item:
269
-
270
- ```ts
271
- const restoredPage = await feedCache.read() // VibePage | null
272
-
273
- const vibe = createVibe({
274
- target: '#gallery',
275
- initialPage: restoredPage ?? undefined,
276
- loadPage,
277
- autofill: {
278
- strategy: 'frontend',
279
- pageSize: 40,
280
- maxAdditionalPages: 'unlimited',
281
- delayStepMs: 2_000,
282
- delayMaxMs: 10_000,
283
- },
284
- onStateChange(state) {
285
- void feedCache.write({
286
- items: [...state.items],
287
- next: state.next,
288
- total: state.total ?? undefined,
289
- })
290
- },
291
- })
292
-
293
- await vibe.mount()
294
- ```
295
-
296
- There is no frontend session snapshot. With 25 restored cards and
297
- `pageSize: 40`, Vibe continues from `initialPage.next` until the load cycle has
298
- at least 40 unique cards. Without `initialPage`, Vibe starts at cursor `null`.
299
- `maxAdditionalPages` accepts a non-negative integer or the serializable string
300
- `'unlimited'`. Unlimited cycles still stop at the target, end of pagination,
301
- a repeated cursor, an error, cancellation/abort, or `destroy()`.
302
-
303
- The first request is immediate. Each subsequent request waits
304
- `min(completedRequests * delayStepMs, delayMaxMs)`, so the defaults produce
305
- 0s, 2s, 4s, 6s, 8s, then 10s between requests. Set both values to `0` when a
306
- frontend source should run without pacing. During a wait,
307
- `state.autofill.delayRemainingMs` counts down and `nextRequestAt` contains the
308
- absolute browser timestamp.
309
-
310
- ### Backend autofill
311
-
312
- Backend autofill lets the consuming application enqueue and cancel durable
313
- work. A restoration endpoint should return the currently committed page and,
314
- when work remains active, its persisted session:
315
-
316
- ```ts
317
- interface BackendRestoration {
318
- initialPage?: VibePage
319
- session?: VibeAutofillSessionSnapshot
320
- }
321
-
322
- const restoration: BackendRestoration = await getBackendRestoration()
323
-
324
- const vibe = createVibe({
325
- target: '#gallery',
326
- initialPage: restoration.initialPage,
327
- loadPage,
328
- autofill: {
329
- strategy: 'backend',
330
- pageSize: 40,
331
- feedKey: 'latest-images',
332
- initialSession: restoration.session,
333
- async onUnderfilled(context) {
334
- const { signal, ...payload } = context
335
- return await startBackendAutofill(payload, { signal })
336
- },
337
- onCancel: cancelBackendAutofill,
338
- },
339
- })
340
-
341
- const unsubscribe = subscribeToAutofillUpdates((update) => {
342
- vibe.applyAutofillUpdate(update)
343
- })
344
-
345
- await vibe.mount()
346
- ```
347
-
348
- Subscribe before mounting so a fast Reverb, WebSocket, or polling update is not
349
- missed. A waiting update reports sequenced progress without items. A `complete`
350
- or `exhausted` update supplies the entire buffered batch and appends it once:
351
-
352
- The backend owns its delay policy and job scheduling. Return or emit the
353
- absolute `nextRequestAt` timestamp for a waiting job; Vibe derives the live
354
- `delayRemainingMs` countdown locally. Persist the timestamp in session
355
- snapshots so a refreshed client can resume the same countdown.
356
-
357
- ```ts
358
- vibe.applyAutofillUpdate({
359
- feedKey: 'latest-images',
360
- sessionId: 'job-456',
361
- sequence: 2,
362
- requests: 2,
363
- received: 48,
364
- nextRequestAt: null,
365
- status: 'complete',
366
- items: completedBatch,
367
- next: 'cursor-after-completed-batch',
368
- total: 500,
369
- })
370
-
371
- await vibe.cancelAutofill()
372
- unsubscribe()
373
- vibe.destroy()
374
- ```
375
-
376
- Load-time restoration should provide both `initialPage` and
377
- `autofill.initialSession`; this renders committed items immediately and avoids
378
- starting a duplicate backend job. The snapshot `feedKey` and `pageSize` must
379
- match the configured backend autofill options. Use
380
- `vibe.restoreAutofillSession(snapshot)` only when a session becomes available
381
- after construction; it restores autofill lifecycle state, not a complete
382
- visible page.
383
-
384
- `state.autofill` exposes the strategy, lifecycle status, target, received and
385
- missing counts, request count, countdown, session identity, sequence, and error. Backend
386
- results are appended only when an update is `complete` or `exhausted`; stale,
387
- duplicate, mismatched-feed, and post-cancellation updates are ignored.
388
- Terminal backend updates must include `next`, using `null` when the source is
389
- exhausted. Vibe resumes ordinary pagination from that terminal cursor after
390
- autofill completes. A terminal update without `next` is rejected so an older
391
- cursor cannot be requested again.
392
-
393
- ## Manual fill
394
-
395
- Manual fill is separate from autofill. It starts only when the consumer calls
396
- `vibe.fill()` and targets additional page requests rather than an item count:
397
-
398
- ```ts
399
- await vibe.fill({ pages: 3 })
400
- await vibe.fill({ until: 'end' })
401
- ```
402
-
403
- `{ pages: 3 }` follows three cursors from the currently loaded page. It becomes
404
- `exhausted` if the source ends before all three requests complete. `{ until:
405
- 'end' }` continues until a response returns `next: null`; reaching that end is
406
- a successful `complete` result. Duplicate post IDs are still removed, but they
407
- do not change how page requests are counted.
408
-
409
- ### Frontend fill
410
-
411
- ```ts
412
- const restoredPage = await feedCache.read()
413
-
414
- const vibe = createVibe({
415
- target: '#gallery',
416
- initialPage: restoredPage ?? undefined,
417
- loadPage,
418
- fill: {
419
- strategy: 'frontend',
420
- delayStepMs: 2_000,
421
- delayMaxMs: 10_000,
422
- },
423
- onStateChange(state) {
424
- renderFillState(state.fill)
425
- void feedCache.write({
426
- items: [...state.items],
427
- next: state.next,
428
- total: state.total ?? undefined,
429
- })
430
- },
431
- })
432
-
433
- await vibe.mount()
434
- await vibe.fill({ pages: 3 })
435
- // or: await vibe.fill({ until: 'end' })
436
- ```
437
-
438
- Frontend fill invokes `loadPage` in the browser, exposes progress through
439
- `state.fill`, and appends one buffered batch when the requested pages or end is
440
- reached. `await vibe.cancelFill()` aborts the active request and discards the
441
- uncommitted batch. A restored `initialPage.next` is the cursor from which the
442
- next manual fill starts. The first fill request is immediate; subsequent
443
- requests use the same capped incremental delay and expose
444
- `state.fill.delayRemainingMs` plus `state.fill.nextRequestAt`.
445
-
446
- ### Backend fill
447
-
448
- ```ts
449
- interface FillRestoration {
450
- initialPage?: VibePage
451
- session?: VibeFillSessionSnapshot
452
- }
453
-
454
- const restoration: FillRestoration = await getFillRestoration()
455
-
456
- const vibe = createVibe({
457
- target: '#gallery',
458
- initialPage: restoration.initialPage,
459
- loadPage,
460
- fill: {
461
- strategy: 'backend',
462
- feedKey: 'latest-images',
463
- initialSession: restoration.session,
464
- async onStart(context) {
465
- const { signal, ...payload } = context
466
- return await startBackendFill(payload, { signal })
467
- },
468
- onCancel: cancelBackendFill,
469
- },
470
- })
471
-
472
- const unsubscribe = subscribeToFillUpdates((update) => {
473
- vibe.applyFillUpdate(update)
474
- })
475
-
476
- await vibe.mount()
477
- await vibe.fill({ pages: 3 })
478
- ```
479
-
480
- The backend callback should enqueue durable work and return a `sessionId`
481
- without waiting for every page. Each completed endpoint call can emit a
482
- sequenced `waiting` update containing `completedPages` and `received`. The
483
- backend remains responsible for delaying and dispatching each job. Include its
484
- absolute `nextRequestAt` in the returned session and waiting updates so Vibe can
485
- render the countdown. The terminal update supplies the entire buffered batch
486
- and its cursor boundary:
487
-
488
- ```ts
489
- vibe.applyFillUpdate({
490
- feedKey: 'latest-images',
491
- sessionId: 'fill-job-456',
492
- sequence: 3,
493
- completedPages: 3,
494
- received: 86,
495
- nextRequestAt: null,
496
- status: 'complete',
497
- items: completedBatch,
498
- lastCursor: 'cursor-used-for-page-3',
499
- next: 'cursor-after-page-3',
500
- total: 500,
501
- })
502
- ```
503
-
504
- For load-time restoration, provide both the committed `initialPage` and the
505
- persisted `fill.initialSession`. Subscribe to backend events before mounting.
506
- Use `vibe.restoreFillSession(snapshot)` when a session arrives after
507
- construction. `state.fill` exposes the strategy, target, status, completed page
508
- count, received unique-card count, countdown, session identity, sequence, and error.
509
- Ordinary pagination resumes from the terminal `next` cursor. Stale,
510
- mismatched-feed, invalid-terminal, and post-cancellation updates are ignored.
511
-
512
- ## Auto scroll
513
-
514
- Auto scroll advances the masonry gallery smoothly in pixels per second. It is
515
- disabled by default and can be configured when the instance is created:
516
-
517
- ```ts
518
- const vibe = createVibe({
519
- target: '#gallery',
520
- layout: 'masonry',
521
- loadPage,
522
- autoScroll: {
523
- speedPxPerSecond: 80,
524
- minSpeedPxPerSecond: 20,
525
- maxSpeedPxPerSecond: 240,
526
- },
527
- })
528
-
529
- await vibe.mount()
530
- vibe.setAutoScroll(true, 120)
531
- vibe.pauseAutoScroll()
532
- vibe.resumeAutoScroll()
533
- vibe.setAutoScrollSpeed(160)
534
- vibe.setAutoScroll(false)
535
- ```
536
-
537
- The default speed is 80 px/s within a default 20–240 px/s range. Consumer
538
- values outside the configured range are clamped. `state.autoScroll` exposes the
539
- effective speed, bounds, and `enabled` / `paused` flags. The controller uses the
540
- gallery's animation frames rather than repeated smooth-scroll commands, so
541
- movement remains continuous and responds immediately to speed changes.
542
-
543
- Auto scroll waits while reel layout or a masonry-origin reel viewer is active.
544
- It resumes without a position jump when masonry becomes active again. Infinite
545
- scroll continues to work normally because the gallery remains the only scroll
546
- owner.
547
-
548
- ## Reel auto advance
549
-
550
- Reel auto advance renders a countdown progress strip at the bottom of an active
551
- reel and moves to the next post when the configured interval expires:
552
-
553
- ```ts
554
- const vibe = createVibe({
555
- target: '#gallery',
556
- layout: 'reel',
557
- loadPage,
558
- reelAutoAdvance: {
559
- enabled: true,
560
- intervalMs: 5_000,
561
- includePostItems: false,
562
- },
563
- })
564
-
565
- await vibe.mount()
566
- vibe.setReelAutoAdvance(false)
567
- vibe.setReelAutoAdvance({
568
- enabled: true,
569
- intervalMs: 8_000,
570
- includePostItems: true,
571
- })
572
- ```
573
-
574
- The default interval is five seconds and grouped post items are excluded by
575
- default. When `includePostItems` is enabled, Vibe counts through the parent media
576
- and then each item in its `items` array before moving vertically to the next post.
577
- Still images use the countdown. Audio and video stop looping while auto advance
578
- is enabled and advance from their playback `ended` event instead, so pausing the
579
- media also pauses progression. Manual vertical or horizontal navigation restarts
580
- the countdown for the newly active image. The feature is inactive in masonry and
581
- becomes visible in either a base reel layout or a reel opened from masonry.
582
-
583
- `state.reelAutoAdvance` exposes `enabled`, `intervalMs`, and `includePostItems`.
584
- At the loaded boundary, normal pagination is requested when another cursor is
585
- available; the load-more lock continues to prevent that request when active.
586
-
587
- ## Reel runtime controls
588
-
589
- Use the instance to drive the active reel through the same state paths as
590
- horizontal and vertical reel gestures:
591
-
592
- ```ts
593
- // Media items within the active post (horizontal reel navigation).
594
- vibe.nextReelMediaItem()
595
- vibe.previousReelMediaItem()
596
-
597
- // Posts within the reel (vertical reel navigation).
598
- vibe.nextReelPost()
599
- vibe.previousReelPost()
600
-
601
- // An exact loaded post/media pair by stable identity.
602
- vibe.navigateToReelItem({ postId: 'post-42', mediaId: 'asset-101' })
603
- ```
604
-
605
- Each navigation method returns `true` when it accepted a navigation and `false`
606
- when it was a no-op. Media-item navigation loops from the first item to the last
607
- and from the last item to the first, matching reel swipes; a post with only one
608
- media item returns `false`. Post navigation does not loop and returns `false` at
609
- the first or last currently loaded post. It does not implicitly load another
610
- page.
611
-
612
- All four methods return `false` before mount, during the initial loading or empty
613
- state, after destroy, and while the instance is showing only its masonry feed.
614
- They work in both a base reel layout and an open masonry-origin reel viewer.
615
- Successful navigation updates `state.activeReelPostId`, the selected media item,
616
- card-region props, route synchronization, and an open information sheet's
617
- consumer context through the same handlers used by gestures and keyboard input.
618
-
619
- `navigateToReelItem()` returns `navigated` when both identities resolve,
620
- including when the requested media is already active. It returns `not-found`
621
- when the loaded post or media is missing, and `reel-inactive` before mount,
622
- after destroy, during initial loading, or while only masonry is visible. The
623
- method does not load, insert, or fall back to another item. Every targetable
624
- parent or nested `VibeMediaAsset` must provide its stable `mediaId`.
625
-
626
- ## Reel information sheet
627
-
628
- Provide an application-owned Vue component when a reel should offer a
629
- customizable information sheet. The component is fixed for the lifetime of the
630
- Vibe instance, while its open state can be initialized and changed at runtime:
631
-
632
- ```ts
633
- import ReelInformationSheet from './ReelInformationSheet.vue'
634
-
635
- const vibe = createVibe({
636
- target: '#gallery',
637
- layout: 'responsive',
638
- loadPage,
639
- reelInfoSheet: {
640
- component: ReelInformationSheet,
641
- enabled: false,
642
- },
643
- })
644
-
645
- await vibe.mount()
646
- vibe.setReelInfoSheet(true)
647
- vibe.setReelInfoSheet(false)
648
- ```
649
-
650
- Import `VibeReelInfoSheetProps` to type the consumer component. It receives the
651
- active `item`, zero-based post and media indexes, the active media item and
652
- source, inclusive media count, loaded and optional total counts, `layout:
653
- 'reel'`, whether the reel originated from `reel` or `masonry`, and a `close()`
654
- callback. The component owns its visible controls and content:
655
-
656
- ```vue
657
- <script setup lang="ts">
658
- import type { VibeReelInfoSheetProps } from '@wyxos/vibe'
659
-
660
- defineProps<VibeReelInfoSheetProps>()
661
- </script>
662
-
663
- <template>
664
- <aside>
665
- <button type="button" @click="close">Close</button>
666
- <h2>Post {{ item.postId }}</h2>
667
- </aside>
668
- </template>
669
- ```
670
-
671
- Phones render the sheet as a full-width modal overlay and make the reel inert
672
- until it closes. Tablets through 1024px render a full-width non-overlay sheet
673
- below the reel. Larger layouts render it beside the reel at 25% width, changing
674
- to 40% at a 1920px viewport. Non-phone sheets stay mounted while vertical reel
675
- swipes update their active context. All presentations animate open and closed
676
- and honor reduced-motion preferences.
677
-
678
- `state.reelInfoSheet.enabled` reports the requested open state. Enabling without
679
- a configured component throws an error; disabling is always safe. In a base
680
- reel, Escape closes an open sheet. In a masonry-origin reel, Escape closes the
681
- viewer without disabling the sheet, so it is open again when another masonry
682
- card opens a reel. The consumer close control or `setReelInfoSheet(false)`
683
- explicitly disables it. The underlying masonry renderer stays mounted, so
684
- closing either surface preserves its scroll, media selections, and focus target.
685
- Calling `setReelInfoSheet()` before mount, during loading, or outside an active
686
- reel still updates this requested state; the sheet becomes visible when a valid
687
- reel context exists. This preserves the existing persistent sheet-state
688
- behavior across masonry viewer closes.
689
-
690
- When a sheet contains another Vibe instance, Escape is owned by the topmost
691
- active reel. A nested masonry-origin reel therefore closes back to its nested
692
- feed without also closing the parent sheet or reel. Single-instance Escape
693
- behavior is unchanged.
694
-
695
- ## Masonry scroll space and pagination
696
-
697
- ```ts
698
- masonry: {
699
- bottomSpacePx: 200,
700
- loadMoreThresholdPx: 100,
701
- },
702
- ```
703
-
704
- `bottomSpacePx` adds empty scrollable space after the cards (default `0`). It
705
- extends the real scroll height without changing card layout or the virtualization
706
- window. A custom footer remains an overlay and does not reserve this space itself.
707
-
708
- `loadMoreThresholdPx` triggers automatic masonry pagination when the remaining
709
- scroll distance is at most this number of CSS pixels (default `240`). The distance
710
- is measured from the actual bottom, including bottom space and existing padding.
711
- For 1,000px of content plus 200px of extra space, a 100px threshold triggers when
712
- the viewport bottom reaches 1,100px. Zero loads only upon reaching the bottom.
713
- Both values must be finite and non-negative; fractions are allowed. They apply
714
- per instance to masonry only. Reel pagination retains its existing 240px threshold.
715
- `infiniteScroll` and load-more locks retain their usual behavior.
716
-
717
- ## Custom feed footer
718
-
719
- Set `feedFooter.component` to replace the built-in `GalleryFooter`. Consumers
720
- that omit it keep the built-in manual load, retry, and end-of-feed controls.
721
- The manual Load more action remains available when infinite scrolling is
722
- disabled or the rendered feed is too short to scroll. Scrollable feeds using
723
- infinite scrolling keep the action hidden until the viewport becomes
724
- underfilled again.
725
-
726
- ```ts
727
- import FeedFooter from './FeedFooter.vue'
728
-
729
- const vibe = createVibe({
730
- target: '#gallery',
731
- loadPage,
732
- feedFooter: {
733
- component: FeedFooter,
734
- },
735
- })
736
- ```
737
-
738
- Type the component with `VibeFeedFooterProps`. Its optional `showLoadMore` prop reports
739
- when manual pagination is needed because infinite scrolling is disabled or the
740
- rendered feed is too short to scroll. Its `state` prop is the reactive
741
- public `VibeState`, including autofill progress, request count, countdown,
742
- loading, end, error, and load-more-lock state. Its `actions` prop contains
743
- `loadMore()`, `retryEnd()`, `retry()`, and `cancelAutofill()`:
744
-
745
- ```vue
746
- <script setup lang="ts">
747
- import type { VibeFeedFooterProps } from '@wyxos/vibe'
748
-
749
- const props = defineProps<VibeFeedFooterProps>()
750
- </script>
751
-
752
- <template>
753
- <footer>
754
- <span>{{ props.state.autofill.received }} / {{ props.state.autofill.pageSize }}</span>
755
- <button type="button" @click="props.actions.cancelAutofill()">Cancel</button>
756
- </footer>
757
- </template>
758
- ```
759
-
760
- A footer may instead emit `load-more`, `retry-end`, `retry`, or
761
- `autofill-cancel`; Vibe routes those events to the same actions. Consumer
762
- components own their markup, styling, state selection, and additional controls.
763
-
764
- ## Load-more lock
765
-
766
- Pause ordinary forward pagination without disabling the feed or cancelling a request
767
- that is already running:
768
-
769
- ```ts
770
- vibe.setLoadMoreLocked(true)
771
-
772
- // Existing items remain interactive. Later, allow pagination again.
773
- vibe.setLoadMoreLocked(false)
774
- ```
775
-
776
- While locked, Vibe ignores `loadNext()`, infinite-scroll boundary triggers, the
777
- load-more action, and the end-of-feed retry. `reload()`, initial loading, autofill,
778
- and explicit `fill()` operations keep their own lifecycles. If infinite scroll is
779
- enabled when the instance is unlocked, Vibe immediately checks the current bottom
780
- boundary so the consumer does not need to nudge the scroll position.
781
-
782
- Read `state.loadMoreLocked` through `getState()` or `onStateChange` when application
783
- chrome needs to reflect the gate. The built-in pagination action is disabled and
784
- shown as `Loading paused` while the gate is active.
785
-
786
- ## Feed state
787
-
788
- Use `onStateChange` to render request and layout state outside Vibe, such as an
789
- application-header lifecycle indicator:
790
-
791
- ```ts
792
- const vibe = createVibe({
793
- target: '#gallery',
794
- loadPage,
795
- onStateChange: (state) => {
796
- renderFeedState({
797
- layout: state.layout,
798
- lifecycle: state.lifecycle,
799
- })
800
- },
801
- })
802
- ```
803
-
804
- The callback receives an initial snapshot when the instance is created and a
805
- new snapshot whenever its public state changes. The typed `lifecycle` value is
806
- `loading`, `loaded`, or `error`; it covers initial and pagination requests.
807
- Individual media preview errors do not mark the feed request as failed.
808
-
809
- ## Instance lifecycle
810
-
811
- ```ts
812
- vibe.setLayout('reel')
813
- vibe.setLayout('responsive')
814
- vibe.setInfiniteScroll(false)
815
- vibe.setLoadMoreLocked(true)
816
- vibe.setLoadMoreLocked(false)
817
- vibe.setReelAutoAdvance(true)
818
- vibe.setReelAutoAdvance({ includePostItems: true, intervalMs: 8_000 })
819
- vibe.setReelInfoSheet(true)
820
- vibe.setReelInfoSheet(false)
821
- vibe.nextReelMediaItem()
822
- vibe.previousReelMediaItem()
823
- vibe.nextReelPost()
824
- vibe.previousReelPost()
825
- vibe.navigateToReelItem({ postId: 'post-42', mediaId: 'asset-101' })
826
- vibe.setAutoScroll(true, 80)
827
- vibe.pauseAutoScroll()
828
- vibe.resumeAutoScroll()
829
- vibe.setAutoScroll(false)
830
- await vibe.loadNext()
831
- await vibe.refresh()
832
- await vibe.reload()
833
- vibe.getState()
834
- vibe.destroy()
835
- ```
836
-
837
- `refresh()` replaces the visible feed from its current continuation cursor. At
838
- the end of the feed, it requests the cursor that produced the last loaded page
839
- again. Use `reload()` to replace the feed from its initial `null` cursor instead.
840
- Both operations begin a new feed visit for media visibility callbacks.
841
-
842
- Destroying an instance unmounts its Vue tree and aborts its active page request.
843
-
844
- In masonry layout, activating a card opens that post in a reel layered over the
845
- still-mounted masonry feed. Pressing Escape returns to the same masonry scroll
846
- position. `getState()` reports the viewer session through `reelOrigin` and
847
- `activeReelPostId`; Escape does not exit a reel configured as the base layout.