@wyxos/vibe 3.1.34 → 4.0.0

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