@wyxos/vibe 3.1.35 → 4.0.1

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 (104) hide show
  1. package/README.md +533 -309
  2. package/lib/components/CardRegion.vue.d.ts +15 -0
  3. package/lib/components/GalleryFooter.vue.d.ts +17 -0
  4. package/lib/components/GalleryScrollbar.vue.d.ts +9 -0
  5. package/lib/components/MasonryFeed.vue.d.ts +24 -0
  6. package/lib/components/MediaCard.vue.d.ts +35 -0
  7. package/lib/components/MediaControls.vue.d.ts +22 -0
  8. package/lib/components/ReelAutoAdvanceProgress.vue.d.ts +11 -0
  9. package/lib/components/ReelFeed.vue.d.ts +28 -0
  10. package/lib/components/ReelLayout.vue.d.ts +28 -0
  11. package/lib/components/VibeSurface.vue.d.ts +35 -0
  12. package/lib/core/autoScroll.d.ts +26 -0
  13. package/lib/core/autofill.d.ts +35 -0
  14. package/lib/core/backendAutofill.d.ts +5 -0
  15. package/lib/core/backendFill.d.ts +5 -0
  16. package/lib/core/createVibe.d.ts +2 -0
  17. package/lib/core/feed.d.ts +40 -0
  18. package/lib/core/fill.d.ts +29 -0
  19. package/lib/core/fillController.d.ts +28 -0
  20. package/lib/core/initialAutofill.d.ts +12 -0
  21. package/lib/core/initialRuntimeState.d.ts +3 -0
  22. package/lib/core/masonry.d.ts +37 -0
  23. package/lib/core/mediaAsset.d.ts +5 -0
  24. package/lib/core/mediaPreview.d.ts +3 -0
  25. package/lib/core/mediaType.d.ts +1 -0
  26. package/lib/core/options.d.ts +3 -0
  27. package/lib/core/page.d.ts +3 -0
  28. package/lib/core/reelAutoAdvance.d.ts +5 -0
  29. package/lib/core/reelInfoSheet.d.ts +3 -0
  30. package/lib/core/requestDelay.d.ts +24 -0
  31. package/lib/core/responsiveLayout.d.ts +13 -0
  32. package/lib/core/runtime.d.ts +23 -0
  33. package/lib/core/scrollbar.d.ts +15 -0
  34. package/lib/core/vibeRouting.d.ts +11 -0
  35. package/lib/index.cjs +1 -1
  36. package/lib/index.d.ts +3 -73
  37. package/lib/index.js +2587 -4959
  38. package/lib/style.css +1 -2
  39. package/lib/types.d.ts +323 -0
  40. package/package.json +22 -10
  41. package/lib/components/FullscreenFooter.vue.d.ts +0 -22
  42. package/lib/components/FullscreenForwardFillPlaceholder.vue.d.ts +0 -29
  43. package/lib/components/FullscreenHeader.vue.d.ts +0 -25
  44. package/lib/components/FullscreenMediaBar.vue.d.ts +0 -26
  45. package/lib/components/FullscreenSurface.vue.d.ts +0 -83
  46. package/lib/components/Layout.vue.d.ts +0 -48
  47. package/lib/components/ListCard.vue.d.ts +0 -44
  48. package/lib/components/ListSurface.vue.d.ts +0 -87
  49. package/lib/components/SurfaceEmptyState.vue.d.ts +0 -21
  50. package/lib/components/viewer-core/assetErrors.d.ts +0 -30
  51. package/lib/components/viewer-core/assetState.d.ts +0 -14
  52. package/lib/components/viewer-core/autoBuckets.d.ts +0 -24
  53. package/lib/components/viewer-core/autoResolveBucketFactory.d.ts +0 -15
  54. package/lib/components/viewer-core/autoResolveCursors.d.ts +0 -15
  55. package/lib/components/viewer-core/autoResolveFillUntil.d.ts +0 -47
  56. package/lib/components/viewer-core/autoResolveHelpers.d.ts +0 -20
  57. package/lib/components/viewer-core/autoResolveLoading.d.ts +0 -44
  58. package/lib/components/viewer-core/autoResolveState.d.ts +0 -72
  59. package/lib/components/viewer-core/dom.d.ts +0 -2
  60. package/lib/components/viewer-core/dominantImageTone.d.ts +0 -6
  61. package/lib/components/viewer-core/emptyVisiblePrefetch.d.ts +0 -18
  62. package/lib/components/viewer-core/fillDelay.d.ts +0 -10
  63. package/lib/components/viewer-core/fillProgress.d.ts +0 -12
  64. package/lib/components/viewer-core/format.d.ts +0 -1
  65. package/lib/components/viewer-core/fullscreenPreviews.d.ts +0 -8
  66. package/lib/components/viewer-core/itemIdentity.d.ts +0 -6
  67. package/lib/components/viewer-core/listCardAsset.d.ts +0 -4
  68. package/lib/components/viewer-core/listPreview.d.ts +0 -10
  69. package/lib/components/viewer-core/loadError.d.ts +0 -5
  70. package/lib/components/viewer-core/masonryBoundaryLock.d.ts +0 -5
  71. package/lib/components/viewer-core/masonryItemMutation.d.ts +0 -35
  72. package/lib/components/viewer-core/masonryLayout.d.ts +0 -64
  73. package/lib/components/viewer-core/masonryLayoutState.d.ts +0 -22
  74. package/lib/components/viewer-core/masonryPendingAppend.d.ts +0 -19
  75. package/lib/components/viewer-core/masonryScrollBehavior.d.ts +0 -37
  76. package/lib/components/viewer-core/masonryViewport.d.ts +0 -7
  77. package/lib/components/viewer-core/media.d.ts +0 -4
  78. package/lib/components/viewer-core/mediaPlayback.d.ts +0 -1
  79. package/lib/components/viewer-core/nativeVideoFullscreen.d.ts +0 -2
  80. package/lib/components/viewer-core/removalState.d.ts +0 -71
  81. package/lib/components/viewer-core/slotContent.d.ts +0 -2
  82. package/lib/components/viewer-core/surfaceSlots.d.ts +0 -33
  83. package/lib/components/viewer-core/surfaceStatus.d.ts +0 -18
  84. package/lib/components/viewer-core/theme.d.ts +0 -3
  85. package/lib/components/viewer-core/useActivation.d.ts +0 -8
  86. package/lib/components/viewer-core/useAssetLoadQueue.d.ts +0 -17
  87. package/lib/components/viewer-core/useAutoResolveSource.d.ts +0 -68
  88. package/lib/components/viewer-core/useController.d.ts +0 -98
  89. package/lib/components/viewer-core/useDataSource.d.ts +0 -99
  90. package/lib/components/viewer-core/useEdgeBoundary.d.ts +0 -18
  91. package/lib/components/viewer-core/useFullscreen.d.ts +0 -1
  92. package/lib/components/viewer-core/useFullscreenAssetEvents.d.ts +0 -24
  93. package/lib/components/viewer-core/useFullscreenDominantTone.d.ts +0 -12
  94. package/lib/components/viewer-core/useFullscreenPreloadController.d.ts +0 -18
  95. package/lib/components/viewer-core/useFullscreenSurfaceMedia.d.ts +0 -32
  96. package/lib/components/viewer-core/useListCardHealthCheck.d.ts +0 -20
  97. package/lib/components/viewer-core/useMasonryList.d.ts +0 -85
  98. package/lib/components/viewer-core/useMasonryMotion.d.ts +0 -76
  99. package/lib/components/viewer-core/useMedia.d.ts +0 -44
  100. package/lib/components/viewer-core/useSurfaceEmptyState.d.ts +0 -15
  101. package/lib/components/viewer-core/useVideoFullscreen.d.ts +0 -9
  102. package/lib/components/viewer-core/useViewer.d.ts +0 -78
  103. package/lib/components/viewer-core/virtualization.d.ts +0 -18
  104. package/lib/components/viewer.d.ts +0 -23
package/README.md CHANGED
@@ -1,421 +1,645 @@
1
1
  # Vibe
2
2
 
3
- Vibe is an opinionated Vue 3 mixed-media feed and viewer engine for large datasets.
3
+ Vibe is an initializable Vue 3 media feed with virtualized masonry and reel layouts.
4
4
 
5
- It is built for apps that need a virtualized desktop feed, fullscreen browsing, library-owned paging, removals, retries, and asset-failure handling without stitching together a masonry grid, lightbox, and feed state by hand.
5
+ Read the [full documentation](./docs/index.md) for guided setup, layout and data-loading concepts, and the API reference.
6
6
 
7
- ![Vibe desktop feed surface](./docs/readme-hero.jpg)
7
+ ## Installation
8
8
 
9
- [Live demo](https://vibe.wyxos.com/) · [In-app docs](https://vibe.wyxos.com/documentation)
9
+ ```bash
10
+ npm install @wyxos/vibe
11
+ ```
10
12
 
11
- ## Built for
13
+ Import the packaged styles once in the consuming application:
12
14
 
13
- Vibe is built for applications that browse large mixed-media collections and need feed state to stay coherent as items load, fail, disappear, and move between desktop and fullscreen surfaces.
15
+ ```ts
16
+ import '@wyxos/vibe/style.css'
17
+ ```
14
18
 
15
- It owns paging, fullscreen browsing, removals, retries, and asset-failure handling as part of one viewer model so those behaviors stay consistent under real feed pressure.
19
+ ## Remote feed
16
20
 
17
- Vibe is hardened in production through [Atlas](https://github.com/wyxos/atlas) as a downstream consumer.
21
+ ```ts
22
+ import { createVibe } from '@wyxos/vibe'
18
23
 
19
- ## When Vibe fits
24
+ const vibe = createVibe({
25
+ target: '#gallery',
26
+ layout: 'responsive',
27
+ loadPage: async ({ cursor, signal }) => {
28
+ const response = await fetch(`/api/media?cursor=${cursor ?? ''}`, {
29
+ signal,
30
+ })
20
31
 
21
- Use Vibe when your app needs:
32
+ return response.json()
33
+ },
34
+ })
22
35
 
23
- - a large mixed-media feed of images, video, audio, and document-like items
24
- - library-owned paging driven by opaque cursors
25
- - a desktop masonry surface that opens into fullscreen
26
- - fullscreen-first behavior on mobile and tablet
27
- - built-in remove, restore, undo, retry, and asset-failure handling
28
- - a small customization surface through slots instead of a headless rendering API
36
+ await vibe.mount()
37
+ ```
29
38
 
30
- ## Quick start
39
+ The target element must have a usable height. Vibe owns scrolling inside that target.
40
+ In responsive layout, Vibe observes the target and uses reels on phones while
41
+ keeping tablets and desktops in masonry. Use `masonry` or `reel` to force a
42
+ renderer instead.
31
43
 
32
- Install the package and import the bundled stylesheet once:
44
+ Phone reels load `preview.src`. Reels on tablets and desktops load the full
45
+ `src`, including a reel forced with `layout: 'reel'` and a reel opened from a
46
+ masonry card.
33
47
 
34
- ```bash
35
- npm i @wyxos/vibe
36
- ```
48
+ `loadPage` receives `cursor: null` for the initial request. It returns normalized Vibe items, the next opaque cursor, and an optional total:
37
49
 
38
50
  ```ts
39
- import '@wyxos/vibe/style.css'
51
+ {
52
+ items: VibeItem[]
53
+ next: string | number | null
54
+ total?: number
55
+ }
40
56
  ```
41
57
 
42
- Tailwind scanning is not required for the package UI.
58
+ ## Static or preloaded feed
43
59
 
44
- ### Plugin install
60
+ ```ts
61
+ const vibe = createVibe({
62
+ target: document.querySelector('#gallery')!,
63
+ layout: 'reel',
64
+ initialPage: {
65
+ items,
66
+ next: null,
67
+ total: items.length,
68
+ },
69
+ })
70
+
71
+ await vibe.mount()
72
+ ```
45
73
 
46
- The default export is the plugin:
74
+ Provide both `initialPage` and `loadPage` when preloaded items can continue to another cursor.
47
75
 
48
- ```ts
49
- import { createApp } from 'vue'
50
- import App from './App.vue'
76
+ ## Custom card chrome
51
77
 
52
- import Vibe from '@wyxos/vibe'
53
- import '@wyxos/vibe/style.css'
78
+ Use `cardHeader` and `cardFooter` to render application-owned regions above and
79
+ below media:
54
80
 
55
- createApp(App)
56
- .use(Vibe)
57
- .mount('#app')
81
+ ```ts
82
+ import MediaInfo from './MediaInfo.vue'
83
+ import MediaReactions from './MediaReactions.vue'
84
+
85
+ const vibe = createVibe({
86
+ target: '#gallery',
87
+ layout: 'responsive',
88
+ loadPage,
89
+ cardHeader: {
90
+ component: MediaInfo,
91
+ height: 40,
92
+ },
93
+ cardFooter: {
94
+ component: MediaReactions,
95
+ height: 48,
96
+ },
97
+ })
58
98
  ```
59
99
 
60
- That registers the global component as `VibeLayout`.
100
+ The heights are CSS pixels and let Vibe include both regions in masonry before
101
+ virtualized cards mount. In reel layout, one header and footer remain stationary
102
+ while only the media viewport scrolls between posts. Each component controls its
103
+ own content and alignment, and receives `item`, `layout`, `mediaSource`, zero-based `index`,
104
+ `loadedCount`, the active `mediaItem`, its zero-based `mediaIndex`, its inclusive
105
+ `mediaCount`, and the optional remote `total`. Import the
106
+ `VibeCardRegionProps` type for typed Vue props. Interacting with these regions
107
+ does not activate the underlying media.
61
108
 
62
- ### Direct import
109
+ When a feed item has entries in `item.items`, Vibe treats the parent media as
110
+ position one and the nested entries as the remaining positions. Masonry cards
111
+ show looping previous/next controls on hover or keyboard focus. Reel layout
112
+ keeps those controls visible and supports native horizontal-wheel or touch-swipe
113
+ navigation through grouped media. Media changes use a directional horizontal
114
+ transition, while vertical reel scrolling continues to move between posts.
63
115
 
64
- If you prefer local registration, import `VibeLayout` directly:
65
-
66
- ```vue
67
- <script setup lang="ts">
68
- import {
69
- VibeLayout,
70
- type VibeResolveParams,
71
- type VibeResolveResult,
72
- } from '@wyxos/vibe'
73
- import '@wyxos/vibe/style.css'
116
+ ## Reel URLs with Vue Router
74
117
 
75
- async function resolve({ cursor, pageSize }: VibeResolveParams): Promise<VibeResolveResult> {
76
- const response = await fetch(`/api/feed?cursor=${cursor ?? ''}&pageSize=${pageSize}`)
77
- return await response.json()
78
- }
79
- </script>
118
+ Pass a Vue Router instance when the application should reflect the active reel
119
+ in its URL. The consumer owns the route names and the item-to-location mapping:
80
120
 
81
- <template>
82
- <VibeLayout :resolve="resolve" />
83
- </template>
121
+ ```ts
122
+ import { useRouter } from 'vue-router'
123
+ import { createVibe } from '@wyxos/vibe'
124
+
125
+ const router = useRouter()
126
+ const vibe = createVibe({
127
+ target: '#gallery',
128
+ loadPage,
129
+ routing: {
130
+ router,
131
+ feed: { name: 'gallery' },
132
+ reel: ({ item }) => ({
133
+ name: 'gallery-file',
134
+ params: { fileId: String(item.postId) },
135
+ }),
136
+ },
137
+ })
84
138
  ```
85
139
 
86
- ## Core concepts
140
+ Vibe pushes the first reel location, replaces it as the active reel changes,
141
+ and replaces it with `feed` when the masonry-origin reel closes or responsive
142
+ layout returns to masonry. The reel callback also receives the zero-based
143
+ `index`, `loadedCount`, optional remote `total`, and whether the reel originated
144
+ from `masonry` or the base `reel` layout. Return `null` to skip a URL update for
145
+ an item. Vue Router is an optional peer dependency and is only needed when this
146
+ integration is used.
147
+
148
+ ## Autofill
149
+
150
+ Autofill targets a number of new top-level cards after grouping and
151
+ deduplication. `autofill.pageSize` is the minimum card target for one Vibe load
152
+ cycle, not the server's own response size. For example, a server may return up
153
+ to 30 cards per request while Vibe is configured with `pageSize: 40`; Vibe then
154
+ follows cursors until the cycle contains at least 40 cards.
87
155
 
88
- ### Item contract
156
+ ### Frontend autofill
89
157
 
90
- Vibe keeps the item contract deliberately small:
158
+ Frontend autofill follows cursors and commits the collected batch once the
159
+ target is reached or the source is exhausted. Restore committed items with
160
+ `initialPage`; its `next` value must follow the last restored item:
91
161
 
92
162
  ```ts
93
- type VibeViewerItem = {
94
- id: string
95
- type: 'image' | 'video' | 'audio' | 'other'
96
- title?: string
97
- url: string
98
- preview?: {
99
- url: string
100
- width?: number
101
- height?: number
102
- mediaType?: 'image' | 'video'
103
- }
104
- healthCheck?: {
105
- url: string
106
- kind?: 'playback'
107
- } | null
108
- width?: number
109
- height?: number
110
- [key: string]: unknown
111
- }
163
+ const restoredPage = await feedCache.read() // VibePage | null
164
+
165
+ const vibe = createVibe({
166
+ target: '#gallery',
167
+ initialPage: restoredPage ?? undefined,
168
+ loadPage,
169
+ autofill: {
170
+ strategy: 'frontend',
171
+ pageSize: 40,
172
+ maxAdditionalPages: 10,
173
+ delayStepMs: 2_000,
174
+ delayMaxMs: 10_000,
175
+ },
176
+ onStateChange(state) {
177
+ void feedCache.write({
178
+ items: [...state.items],
179
+ next: state.next,
180
+ total: state.total ?? undefined,
181
+ })
182
+ },
183
+ })
184
+
185
+ await vibe.mount()
112
186
  ```
113
187
 
114
- Notes:
188
+ There is no frontend session snapshot. With 25 restored cards and
189
+ `pageSize: 40`, Vibe continues from `initialPage.next` until the load cycle has
190
+ at least 40 unique cards. Without `initialPage`, Vibe starts at cursor `null`.
115
191
 
116
- - grid mode prefers `preview.url`, then falls back to `url`
117
- - fullscreen mode uses `url`
118
- - grid layout prefers preview dimensions, then root dimensions, then a square fallback tile
119
- - `other` stays intentionally broad so the consuming app can layer its own file subtypes and icon logic on top
120
- - `healthCheck` is optional and lets the app provide an explicit asset probe, for example when preview URLs are not enough to classify playback health reliably
192
+ The first request is immediate. Each subsequent request waits
193
+ `min(completedRequests * delayStepMs, delayMaxMs)`, so the defaults produce
194
+ 0s, 2s, 4s, 6s, 8s, then 10s between requests. Set both values to `0` when a
195
+ frontend source should run without pacing. During a wait,
196
+ `state.autofill.delayRemainingMs` counts down and `nextRequestAt` contains the
197
+ absolute browser timestamp.
121
198
 
122
- ### Resolve and feed state
199
+ ### Backend autofill
123
200
 
124
- Use `resolve` when you want Vibe to own the paging loop:
201
+ Backend autofill lets the consuming application enqueue and cancel durable
202
+ work. A restoration endpoint should return the currently committed page and,
203
+ when work remains active, its persisted session:
125
204
 
126
205
  ```ts
127
- type VibeResolveParams = {
128
- cursor: string | null
129
- pageSize: number
130
- signal?: AbortSignal
206
+ interface BackendRestoration {
207
+ initialPage?: VibePage
208
+ session?: VibeAutofillSessionSnapshot
131
209
  }
132
210
 
133
- type VibeResolveResult = {
134
- items: VibeViewerItem[]
135
- nextPage: string | null
136
- previousPage?: string | null
137
- }
211
+ const restoration: BackendRestoration = await getBackendRestoration()
212
+
213
+ const vibe = createVibe({
214
+ target: '#gallery',
215
+ initialPage: restoration.initialPage,
216
+ loadPage,
217
+ autofill: {
218
+ strategy: 'backend',
219
+ pageSize: 40,
220
+ feedKey: 'latest-images',
221
+ initialSession: restoration.session,
222
+ async onUnderfilled(context) {
223
+ const { signal, ...payload } = context
224
+ return await startBackendAutofill(payload, { signal })
225
+ },
226
+ onCancel: cancelBackendAutofill,
227
+ },
228
+ })
229
+
230
+ const unsubscribe = subscribeToAutofillUpdates((update) => {
231
+ vibe.applyAutofillUpdate(update)
232
+ })
233
+
234
+ await vibe.mount()
138
235
  ```
139
236
 
140
- Vibe owns:
237
+ Subscribe before mounting so a fast Reverb, WebSocket, or polling update is not
238
+ missed. A waiting update reports sequenced progress without items. A `complete`
239
+ or `exhausted` update supplies the entire buffered batch and appends it once:
141
240
 
142
- - loaded items
143
- - active index
144
- - next-page prefetch
145
- - optional previous-page loading
146
- - duplicate cursor protection
147
- - initial retry state
148
- - removal-aware feed navigation
241
+ The backend owns its delay policy and job scheduling. Return or emit the
242
+ absolute `nextRequestAt` timestamp for a waiting job; Vibe derives the live
243
+ `delayRemainingMs` countdown locally. Persist the timestamp in session
244
+ snapshots so a refreshed client can resume the same countdown.
149
245
 
150
- ### Default paging behavior
246
+ ```ts
247
+ vibe.applyAutofillUpdate({
248
+ feedKey: 'latest-images',
249
+ sessionId: 'job-456',
250
+ sequence: 2,
251
+ requests: 2,
252
+ received: 48,
253
+ nextRequestAt: null,
254
+ status: 'complete',
255
+ items: completedBatch,
256
+ next: 'cursor-after-completed-batch',
257
+ total: 500,
258
+ })
259
+
260
+ await vibe.cancelAutofill()
261
+ unsubscribe()
262
+ vibe.destroy()
263
+ ```
151
264
 
152
- Vibe has one built-in paging model:
265
+ Load-time restoration should provide both `initialPage` and
266
+ `autofill.initialSession`; this renders committed items immediately and avoids
267
+ starting a duplicate backend job. The snapshot `feedKey` and `pageSize` must
268
+ match the configured backend autofill options. Use
269
+ `vibe.restoreAutofillSession(snapshot)` only when a session becomes available
270
+ after construction; it restores autofill lifecycle state, not a complete
271
+ visible page.
153
272
 
154
- - if a resolve returns fewer visible items than `pageSize`, Vibe enters a fill loop
155
- - it waits `fillDelayMs`, then `fillDelayMs + fillDelayStepMs`, and so on for each chained request, capped by `fillDelayMaxMs` when provided
156
- - it keeps accumulating results until the collected count reaches `pageSize` or there is no further cursor
157
- - then it commits that batch into the layout once
158
- - before advancing at the bottom or top, Vibe checks whether the current boundary page is underfilled after local removals
159
- - if it is, Vibe reloads that same cursor in place first and reconciles only that page's content in the grid
160
- - if that boundary refresh comes back empty, the same boundary attempt can continue to the next or previous cursor
161
- - when the trailing edge is exhausted, another bottom-edge attempt reloads the trailing cursor so newly available pages can be discovered
273
+ `state.autofill` exposes the strategy, lifecycle status, target, received and
274
+ missing counts, request count, countdown, session identity, sequence, and error. Backend
275
+ results are appended only when an update is `complete` or `exhausted`; stale,
276
+ duplicate, mismatched-feed, and post-cancellation updates are ignored.
277
+ Terminal backend updates must include `next`, using `null` when the source is
278
+ exhausted. Vibe resumes ordinary pagination from that terminal cursor after
279
+ autofill completes. A terminal update without `next` is rejected so an older
280
+ cursor cannot be requested again.
162
281
 
163
- Example:
282
+ ## Manual fill
164
283
 
165
- ```vue
166
- <VibeLayout
167
- :resolve="resolve"
168
- :page-size="25"
169
- :fill-delay-ms="2000"
170
- :fill-delay-step-ms="1000"
171
- :fill-delay-max-ms="15000"
172
- :show-end-badge="false"
173
- :show-status-badges="false"
174
- />
284
+ Manual fill is separate from autofill. It starts only when the consumer calls
285
+ `vibe.fill()` and targets additional page requests rather than an item count:
286
+
287
+ ```ts
288
+ await vibe.fill({ pages: 3 })
289
+ await vibe.fill({ until: 'end' })
175
290
  ```
176
291
 
177
- ### Seeded hydration
292
+ `{ pages: 3 }` follows three cursors from the currently loaded page. It becomes
293
+ `exhausted` if the source ends before all three requests complete. `{ until:
294
+ 'end' }` continues until a response returns `next: null`; reaching that end is
295
+ a successful `complete` result. Duplicate post IDs are still removed, but they
296
+ do not change how page requests are counted.
178
297
 
179
- Use `initialState` when the app already knows a restored slice of the feed and wants Vibe to hydrate from that snapshot immediately:
298
+ ### Frontend fill
180
299
 
181
- ```vue
182
- <VibeLayout
183
- :resolve="resolve"
184
- :initial-state="{
185
- cursor: 'page-10',
186
- items: restoredItems,
187
- nextCursor: 'page-11',
188
- previousCursor: 'page-9',
189
- activeIndex: 4,
190
- }"
191
- />
300
+ ```ts
301
+ const restoredPage = await feedCache.read()
302
+
303
+ const vibe = createVibe({
304
+ target: '#gallery',
305
+ initialPage: restoredPage ?? undefined,
306
+ loadPage,
307
+ fill: {
308
+ strategy: 'frontend',
309
+ delayStepMs: 2_000,
310
+ delayMaxMs: 10_000,
311
+ },
312
+ onStateChange(state) {
313
+ renderFillState(state.fill)
314
+ void feedCache.write({
315
+ items: [...state.items],
316
+ next: state.next,
317
+ total: state.total ?? undefined,
318
+ })
319
+ },
320
+ })
321
+
322
+ await vibe.mount()
323
+ await vibe.fill({ pages: 3 })
324
+ // or: await vibe.fill({ until: 'end' })
192
325
  ```
193
326
 
194
- Notes:
327
+ Frontend fill invokes `loadPage` in the browser, exposes progress through
328
+ `state.fill`, and appends one buffered batch when the requested pages or end is
329
+ reached. `await vibe.cancelFill()` aborts the active request and discards the
330
+ uncommitted batch. A restored `initialPage.next` is the cursor from which the
331
+ next manual fill starts. The first fill request is immediate; subsequent
332
+ requests use the same capped incremental delay and expose
333
+ `state.fill.delayRemainingMs` plus `state.fill.nextRequestAt`.
195
334
 
196
- - `resolve` is optional if you only need a seeded snapshot
197
- - when `resolve` is present, Vibe continues paging from the seeded cursors
335
+ ### Backend fill
198
336
 
199
- ### Surface behavior
337
+ ```ts
338
+ interface FillRestoration {
339
+ initialPage?: VibePage
340
+ session?: VibeFillSessionSnapshot
341
+ }
200
342
 
201
- - desktop starts in the masonry list surface
202
- - clicking a grid tile opens fullscreen
203
- - `Escape` returns from fullscreen to the list on desktop
204
- - mobile and tablet always force fullscreen
205
- - grid uses preview assets and in-view loading
206
- - fullscreen uses the original asset and shows a spinner until ready
343
+ const restoration: FillRestoration = await getFillRestoration()
344
+
345
+ const vibe = createVibe({
346
+ target: '#gallery',
347
+ initialPage: restoration.initialPage,
348
+ loadPage,
349
+ fill: {
350
+ strategy: 'backend',
351
+ feedKey: 'latest-images',
352
+ initialSession: restoration.session,
353
+ async onStart(context) {
354
+ const { signal, ...payload } = context
355
+ return await startBackendFill(payload, { signal })
356
+ },
357
+ onCancel: cancelBackendFill,
358
+ },
359
+ })
360
+
361
+ const unsubscribe = subscribeToFillUpdates((update) => {
362
+ vibe.applyFillUpdate(update)
363
+ })
364
+
365
+ await vibe.mount()
366
+ await vibe.fill({ pages: 3 })
367
+ ```
207
368
 
208
- Common behavior props:
369
+ The backend callback should enqueue durable work and return a `sessionId`
370
+ without waiting for every page. Each completed endpoint call can emit a
371
+ sequenced `waiting` update containing `completedPages` and `received`. The
372
+ backend remains responsible for delaying and dispatching each job. Include its
373
+ absolute `nextRequestAt` in the returned session and waiting updates so Vibe can
374
+ render the countdown. The terminal update supplies the entire buffered batch
375
+ and its cursor boundary:
209
376
 
210
- - `surfaceMode`: lets the parent drive the desktop list/fullscreen surface explicitly
211
- - `emptyStateMode`: controls whether empty states render inline, as a badge, or stay hidden
212
- - `paginationDetail`: lets the parent attach app-owned cursor or page context to the built-in status UI
213
- - `showDominantImageTone`, `loopFullscreenVideo`, `showEndBadge`, and `showStatusBadges`: tune the built-in fullscreen and status behavior without taking over the surfaces
377
+ ```ts
378
+ vibe.applyFillUpdate({
379
+ feedKey: 'latest-images',
380
+ sessionId: 'fill-job-456',
381
+ sequence: 3,
382
+ completedPages: 3,
383
+ received: 86,
384
+ nextRequestAt: null,
385
+ status: 'complete',
386
+ items: completedBatch,
387
+ lastCursor: 'cursor-used-for-page-3',
388
+ next: 'cursor-after-page-3',
389
+ total: 500,
390
+ })
391
+ ```
214
392
 
215
- ## Customization
393
+ For load-time restoration, provide both the committed `initialPage` and the
394
+ persisted `fill.initialSession`. Subscribe to backend events before mounting.
395
+ Use `vibe.restoreFillSession(snapshot)` when a session arrives after
396
+ construction. `state.fill` exposes the strategy, target, status, completed page
397
+ count, received unique-card count, countdown, session identity, sequence, and error.
398
+ Ordinary pagination resumes from the terminal `next` cursor. Stale,
399
+ mismatched-feed, invalid-terminal, and post-cancellation updates are ignored.
216
400
 
217
- ### Surface slots
401
+ ## Auto scroll
218
402
 
219
- `VibeLayout` exposes a small set of app-owned surfaces instead of a headless render API:
403
+ Auto scroll advances the masonry gallery smoothly in pixels per second. It is
404
+ disabled by default and can be configured when the instance is created:
220
405
 
221
- - `empty-state`
222
- - `fullscreen-aside`
223
- - `fullscreen-footer`
224
- - `fullscreen-header-actions`
225
- - `fullscreen-overlay`
226
- - `fullscreen-status`
227
- - `grid-footer`
228
- - `grid-item-overlay`
229
- - `grid-status`
230
- - `item-icon`
406
+ ```ts
407
+ const vibe = createVibe({
408
+ target: '#gallery',
409
+ layout: 'masonry',
410
+ loadPage,
411
+ autoScroll: {
412
+ speedPxPerSecond: 80,
413
+ minSpeedPxPerSecond: 20,
414
+ maxSpeedPxPerSecond: 240,
415
+ },
416
+ })
417
+
418
+ await vibe.mount()
419
+ vibe.setAutoScroll(true, 120)
420
+ vibe.pauseAutoScroll()
421
+ vibe.resumeAutoScroll()
422
+ vibe.setAutoScrollSpeed(160)
423
+ vibe.setAutoScroll(false)
424
+ ```
231
425
 
232
- Example:
426
+ The default speed is 80 px/s within a default 20–240 px/s range. Consumer
427
+ values outside the configured range are clamped. `state.autoScroll` exposes the
428
+ effective speed, bounds, and `enabled` / `paused` flags. The controller uses the
429
+ gallery's animation frames rather than repeated smooth-scroll commands, so
430
+ movement remains continuous and responds immediately to speed changes.
233
431
 
234
- ```vue
235
- <VibeLayout :resolve="resolve">
236
- <template #item-icon="{ item, icon }">
237
- <component :is="item.type === 'other' ? MyCustomIcon : icon" />
238
- </template>
239
-
240
- <template #grid-item-overlay="{ item, hovered }">
241
- <div v-if="hovered" class="absolute inset-x-0 bottom-0 p-3">
242
- <div class="pointer-events-auto bg-black/45 px-3 py-2 backdrop-blur">
243
- Like {{ item.title }}
244
- </div>
245
- </div>
246
- </template>
247
-
248
- <template #grid-footer>
249
- <div class="bg-black/55 px-4 py-3 backdrop-blur">
250
- Custom footer UI
251
- </div>
252
- </template>
253
- </VibeLayout>
254
- ```
432
+ Auto scroll waits while reel layout or a masonry-origin reel viewer is active.
433
+ It resumes without a position jump when masonry becomes active again. Infinite
434
+ scroll continues to work normally because the gallery remains the only scroll
435
+ owner.
255
436
 
256
- ### Exposed handle
437
+ ## Reel auto advance
257
438
 
258
- Get a component ref and use `VibeHandle`:
439
+ Reel auto advance renders a countdown progress strip at the bottom of an active
440
+ reel and moves to the next post when the configured interval expires:
259
441
 
260
442
  ```ts
261
- import { ref } from 'vue'
262
- import type { VibeHandle } from '@wyxos/vibe'
263
-
264
- const vibe = ref<VibeHandle | null>(null)
443
+ const vibe = createVibe({
444
+ target: '#gallery',
445
+ layout: 'reel',
446
+ loadPage,
447
+ reelAutoAdvance: {
448
+ enabled: true,
449
+ intervalMs: 5_000,
450
+ includePostItems: false,
451
+ },
452
+ })
453
+
454
+ await vibe.mount()
455
+ vibe.setReelAutoAdvance(false)
456
+ vibe.setReelAutoAdvance({
457
+ enabled: true,
458
+ intervalMs: 8_000,
459
+ includePostItems: true,
460
+ })
265
461
  ```
266
462
 
267
- ```vue
268
- <VibeLayout ref="vibe" :resolve="resolve" />
269
- ```
463
+ The default interval is five seconds and grouped post items are excluded by
464
+ default. When `includePostItems` is enabled, Vibe counts through the parent media
465
+ and then each item in its `items` array before moving vertically to the next post.
466
+ Still images use the countdown. Audio and video stop looping while auto advance
467
+ is enabled and advance from their playback `ended` event instead, so pausing the
468
+ media also pauses progression. Manual vertical or horizontal navigation restarts
469
+ the countdown for the newly active image. The feature is inactive in masonry and
470
+ becomes visible in either a base reel layout or a reel opened from masonry.
270
471
 
271
- Available methods:
472
+ `state.reelAutoAdvance` exposes `enabled`, `intervalMs`, and `includePostItems`.
473
+ At the loaded boundary, normal pagination is requested when another cursor is
474
+ available; the load-more lock continues to prevent that request when active.
272
475
 
273
- - `lockPageLoading()`
274
- - `unlockPageLoading()`
275
- - `loadNext()`
276
- - `loadPrevious()`
277
- - `retry()`
278
- - `cancel()`
279
- - `remove(ids)`
280
- - `restore(ids)`
281
- - `undo()`
282
- - `getRemovedIds()`
283
- - `clearRemoved()`
476
+ ## Reel runtime controls
284
477
 
285
- Available state:
478
+ Use the instance to drive the active reel through the same state paths as
479
+ horizontal and vertical reel gestures:
286
480
 
287
481
  ```ts
288
- type VibeStatus = {
289
- activeIndex: number
290
- currentCursor: string | null
291
- errorMessage: string | null
292
- fillCollectedCount: number | null
293
- fillCursor: string | null
294
- fillDelayRemainingMs: number | null
295
- fillTargetCount: number | null
296
- hasNextPage: boolean
297
- hasPreviousPage: boolean
298
- itemCount: number
299
- loadState: 'failed' | 'loaded' | 'loading'
300
- nextBoundaryLoadProgress: number
301
- nextCursor: string | null
302
- pageLoadingLocked: boolean
303
- phase: 'failed' | 'filling' | 'idle' | 'initializing' | 'loading' | 'refreshing'
304
- previousBoundaryLoadProgress: number
305
- previousCursor: string | null
306
- removedCount: number
307
- removedIds: readonly string[]
308
- surfaceMode: 'fullscreen' | 'list'
309
- }
310
- ```
482
+ // Media items within the active post (horizontal reel navigation).
483
+ vibe.nextReelMediaItem()
484
+ vibe.previousReelMediaItem()
311
485
 
312
- Example:
313
-
314
- ```ts
315
- vibe.value?.lockPageLoading()
316
- vibe.value?.remove(['item-2', 'item-5'])
317
- vibe.value?.undo()
318
- vibe.value?.unlockPageLoading()
319
- console.log(vibe.value?.status.itemCount)
320
- console.log(vibe.value?.status.pageLoadingLocked)
321
- console.log(vibe.value?.status.nextBoundaryLoadProgress)
322
- console.log(vibe.value?.status.removedIds)
486
+ // Posts within the reel (vertical reel navigation).
487
+ vibe.nextReelPost()
488
+ vibe.previousReelPost()
323
489
  ```
324
490
 
325
- ### Events
491
+ Each navigation method returns `true` when it accepted a navigation and `false`
492
+ when it was a no-op. Media-item navigation loops from the first item to the last
493
+ and from the last item to the first, matching reel swipes; a post with only one
494
+ media item returns `false`. Post navigation does not loop and returns `false` at
495
+ the first or last currently loaded post. It does not implicitly load another
496
+ page.
326
497
 
327
- `VibeLayout` emits:
498
+ All four methods return `false` before mount, during the initial loading or empty
499
+ state, after destroy, and while the instance is showing only its masonry feed.
500
+ They work in both a base reel layout and an open masonry-origin reel viewer.
501
+ Successful navigation updates `state.activeReelPostId`, the selected media item,
502
+ card-region props, route synchronization, and an open information sheet's
503
+ consumer context through the same handlers used by gestures and keyboard input.
328
504
 
329
- - `update:activeIndex`
330
- - `update:surfaceMode`
331
- - `asset-loads`
332
- - `asset-errors`
505
+ ## Reel information sheet
333
506
 
334
- `asset-loads` is micro-batched and emits an array payload:
507
+ Provide an application-owned Vue component when a reel should offer a
508
+ customizable information sheet. The component is fixed for the lifetime of the
509
+ Vibe instance, while its open state can be initialized and changed at runtime:
335
510
 
336
511
  ```ts
337
- type VibeAssetLoadEvent = {
338
- item: VibeViewerItem
339
- occurrenceKey: string
340
- url: string
341
- surface: 'grid' | 'fullscreen'
342
- }
512
+ import ReelInformationSheet from './ReelInformationSheet.vue'
513
+
514
+ const vibe = createVibe({
515
+ target: '#gallery',
516
+ layout: 'responsive',
517
+ loadPage,
518
+ reelInfoSheet: {
519
+ component: ReelInformationSheet,
520
+ enabled: false,
521
+ },
522
+ })
523
+
524
+ await vibe.mount()
525
+ vibe.setReelInfoSheet(true)
526
+ vibe.setReelInfoSheet(false)
343
527
  ```
344
528
 
345
- `asset-errors` is micro-batched and emits an array payload:
346
-
347
- ```ts
348
- type VibeAssetErrorEvent = {
349
- item: VibeViewerItem
350
- occurrenceKey: string
351
- url: string
352
- kind: 'generic' | 'not-found'
353
- surface: 'grid' | 'fullscreen'
354
- }
355
- ```
356
-
357
- Example:
529
+ Import `VibeReelInfoSheetProps` to type the consumer component. It receives the
530
+ active `item`, zero-based post and media indexes, the active media item and
531
+ source, inclusive media count, loaded and optional total counts, `layout:
532
+ 'reel'`, whether the reel originated from `reel` or `masonry`, and a `close()`
533
+ callback. The component owns its visible controls and content:
358
534
 
359
535
  ```vue
360
536
  <script setup lang="ts">
361
- import type { VibeAssetErrorEvent, VibeAssetLoadEvent } from '@wyxos/vibe'
537
+ import type { VibeReelInfoSheetProps } from '@wyxos/vibe'
362
538
 
363
- function onAssetLoads(loads: VibeAssetLoadEvent[]) {
364
- console.log(loads)
365
- }
366
-
367
- function onAssetErrors(errors: VibeAssetErrorEvent[]) {
368
- console.log(errors)
369
- }
539
+ defineProps<VibeReelInfoSheetProps>()
370
540
  </script>
371
541
 
372
542
  <template>
373
- <VibeLayout
374
- :resolve="resolve"
375
- @asset-loads="onAssetLoads"
376
- @asset-errors="onAssetErrors"
377
- />
543
+ <aside>
544
+ <button type="button" @click="close">Close</button>
545
+ <h2>Post {{ item.postId }}</h2>
546
+ </aside>
378
547
  </template>
379
548
  ```
380
549
 
381
- Notes:
550
+ Phones render the sheet as a full-width modal overlay and make the reel inert
551
+ until it closes. Tablets through 1024px render a full-width non-overlay sheet
552
+ below the reel. Larger layouts render it beside the reel at 25% width, changing
553
+ to 40% at a 1920px viewport. Non-phone sheets stay mounted while vertical reel
554
+ swipes update their active context. All presentations animate open and closed
555
+ and honor reduced-motion preferences.
556
+
557
+ `state.reelInfoSheet.enabled` reports the requested open state. Enabling without
558
+ a configured component throws an error; disabling is always safe. In a base
559
+ reel, Escape closes an open sheet. In a masonry-origin reel, Escape closes the
560
+ viewer without disabling the sheet, so it is open again when another masonry
561
+ card opens a reel. The consumer close control or `setReelInfoSheet(false)`
562
+ explicitly disables it. The underlying masonry renderer stays mounted, so
563
+ closing either surface preserves its scroll, media selections, and focus target.
564
+ Calling `setReelInfoSheet()` before mount, during loading, or outside an active
565
+ reel still updates this requested state; the sheet becomes visible when a valid
566
+ reel context exists. This preserves the existing persistent sheet-state
567
+ behavior across masonry viewer closes.
568
+
569
+ ## Load-more lock
570
+
571
+ Pause ordinary forward pagination without disabling the feed or cancelling a request
572
+ that is already running:
382
573
 
383
- - successful loads that happen close together are batched into one event
384
- - grid load events usually report `preview.url` when a preview exists
385
- - fullscreen load events report `item.url`
386
- - multiple failures that happen close together are batched into one event
387
- - identical failures are deduped inside the same batch
388
- - if the same item fails again later, it can emit again in a later batch
389
- - built-in Vibe error surfaces allow retrying `generic` failures, but not `not-found`
574
+ ```ts
575
+ vibe.setLoadMoreLocked(true)
390
576
 
391
- ## Local demo routes
577
+ // Existing items remain interactive. Later, allow pagination again.
578
+ vibe.setLoadMoreLocked(false)
579
+ ```
392
580
 
393
- Run:
581
+ While locked, Vibe ignores `loadNext()`, infinite-scroll boundary triggers, the
582
+ load-more action, and the end-of-feed retry. `reload()`, initial loading, autofill,
583
+ and explicit `fill()` operations keep their own lifecycles. If infinite scroll is
584
+ enabled when the instance is unlocked, Vibe immediately checks the current bottom
585
+ boundary so the consumer does not need to nudge the scroll position.
394
586
 
395
- ```bash
396
- npm run dev
397
- ```
587
+ Read `state.loadMoreLocked` through `getState()` or `onStateChange` when application
588
+ chrome needs to reflect the gate. The built-in pagination action is disabled and
589
+ shown as `Loading paused` while the gate is active.
590
+
591
+ ## Feed state
592
+
593
+ Use `onStateChange` to render request and layout state outside Vibe, such as an
594
+ application-header lifecycle indicator:
398
595
 
399
- Routes:
596
+ ```ts
597
+ const vibe = createVibe({
598
+ target: '#gallery',
599
+ loadPage,
600
+ onStateChange: (state) => {
601
+ renderFeedState({
602
+ layout: state.layout,
603
+ lifecycle: state.lifecycle,
604
+ })
605
+ },
606
+ })
607
+ ```
400
608
 
401
- - `/` - default feed surface
402
- - `/documentation` - in-app documentation
403
- - `/demo/feed-behavior` - unified fill, retry, boundary refresh, removals, and paging-lock demo
404
- - `/debug/fake-server` - fake-server inspection route
609
+ The callback receives an initial snapshot when the instance is created and a
610
+ new snapshot whenever its public state changes. The typed `lifecycle` value is
611
+ `loading`, `loaded`, or `error`; it covers initial and pagination requests.
612
+ Individual media preview errors do not mark the feed request as failed.
405
613
 
406
- ## Local development
614
+ ## Instance lifecycle
407
615
 
408
- ```bash
409
- npm run check
410
- npm run build
411
- npm run build:lib
412
- npm run build:types
413
- npm run test:unit
414
- npm run test:e2e
616
+ ```ts
617
+ vibe.setLayout('reel')
618
+ vibe.setLayout('responsive')
619
+ vibe.setInfiniteScroll(false)
620
+ vibe.setLoadMoreLocked(true)
621
+ vibe.setLoadMoreLocked(false)
622
+ vibe.setReelAutoAdvance(true)
623
+ vibe.setReelAutoAdvance({ includePostItems: true, intervalMs: 8_000 })
624
+ vibe.setReelInfoSheet(true)
625
+ vibe.setReelInfoSheet(false)
626
+ vibe.nextReelMediaItem()
627
+ vibe.previousReelMediaItem()
628
+ vibe.nextReelPost()
629
+ vibe.previousReelPost()
630
+ vibe.setAutoScroll(true, 80)
631
+ vibe.pauseAutoScroll()
632
+ vibe.resumeAutoScroll()
633
+ vibe.setAutoScroll(false)
634
+ await vibe.loadNext()
635
+ await vibe.reload()
636
+ vibe.getState()
637
+ vibe.destroy()
415
638
  ```
416
639
 
417
- ## Notes
640
+ Destroying an instance unmounts its Vue tree and aborts its active page request.
418
641
 
419
- - `lib/` is generated output
420
- - source of truth is under `src/`
421
- - the package ships compiled CSS at `@wyxos/vibe/style.css`
642
+ In masonry layout, activating a card opens that post in a reel layered over the
643
+ still-mounted masonry feed. Pressing Escape returns to the same masonry scroll
644
+ position. `getState()` reports the viewer session through `reelOrigin` and
645
+ `activeReelPostId`; Escape does not exit a reel configured as the base layout.