@wyxos/vibe 1.4.1 → 1.5.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.
package/lib/vibe.css CHANGED
@@ -1 +1 @@
1
- .masonry-container[data-v-a75cd886]{overflow-anchor:none}.masonry-item[data-v-a75cd886]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-a75cd886]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-a75cd886],.masonry-container:not(.force-motion) .masonry-move[data-v-a75cd886]{transition-duration:1ms!important}}
1
+ .masonry-container[data-v-b855ca99]{overflow-anchor:none}.masonry-item[data-v-b855ca99]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-b855ca99]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-b855ca99],.masonry-container:not(.force-motion) .masonry-move[data-v-b855ca99]{transition-duration:1ms!important}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/vibe",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "main": "lib/index.js",
5
5
  "module": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -42,9 +42,10 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@tailwindcss/vite": "^4.0.15",
45
+ "@types/lodash-es": "^4.17.12",
46
+ "@types/node": "^24.5.2",
45
47
  "@vitejs/plugin-vue": "^5.2.1",
46
48
  "@vue/test-utils": "^2.4.6",
47
- "@types/node": "^24.5.2",
48
49
  "chalk": "^5.3.0",
49
50
  "inquirer": "^10.1.8",
50
51
  "jsdom": "^26.0.0",
package/src/App.vue CHANGED
@@ -8,6 +8,8 @@ const items = ref<MasonryItem[]>([]);
8
8
 
9
9
  const masonry = ref<InstanceType<typeof Masonry> | null>(null);
10
10
 
11
+ const layout = { sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 10 }, header: 40, footer: 40 };
12
+
11
13
  const getPage = async (page: number): Promise<GetPageResult> => {
12
14
  return new Promise((resolve) => {
13
15
  setTimeout(() => {
@@ -49,7 +51,7 @@ const getPage = async (page: number): Promise<GetPageResult> => {
49
51
  <p>Showing: <span class="bg-blue-500 text-white p-2 rounded">{{ items.length }}</span></p>
50
52
  </div>
51
53
  </header>
52
- <masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" ref="masonry">
54
+ <masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" :layout="layout" ref="masonry">
53
55
  <template #item="{item, remove}">
54
56
  <img :src="item.src" class="w-full"/>
55
57
  <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="remove(item)">
package/src/Masonry.vue CHANGED
@@ -36,10 +36,6 @@ const props = defineProps({
36
36
  type: Boolean,
37
37
  default: false
38
38
  },
39
- maxItems: {
40
- type: Number,
41
- default: 100
42
- },
43
39
  pageSize: {
44
40
  type: Number,
45
41
  default: 40
@@ -87,6 +83,14 @@ const props = defineProps({
87
83
  forceMotion: {
88
84
  type: Boolean,
89
85
  default: false
86
+ },
87
+ virtualBufferPx: {
88
+ type: Number,
89
+ default: 600
90
+ },
91
+ loadThresholdPx: {
92
+ type: Number,
93
+ default: 200
90
94
  }
91
95
  })
92
96
 
@@ -117,7 +121,8 @@ const emits = defineEmits([
117
121
  'backfill:stop',
118
122
  'retry:start',
119
123
  'retry:tick',
120
- 'retry:stop'
124
+ 'retry:stop',
125
+ 'remove-all:complete'
121
126
  ])
122
127
 
123
128
  const masonry = computed<any>({
@@ -128,25 +133,72 @@ const masonry = computed<any>({
128
133
  const columns = ref<number>(7)
129
134
  const container = ref<HTMLElement | null>(null)
130
135
  const paginationHistory = ref<any[]>([])
131
- const nextPage = ref<number | null>(null)
132
136
  const isLoading = ref<boolean>(false)
133
137
  const containerHeight = ref<number>(0)
134
138
 
139
+ // Diagnostics: track items missing width/height to help developers
140
+ const invalidDimensionIds = ref<Set<number | string>>(new Set())
141
+ function isPositiveNumber(value: unknown): boolean {
142
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
143
+ }
144
+ function checkItemDimensions(items: any[], context: string) {
145
+ try {
146
+ if (!Array.isArray(items) || items.length === 0) return
147
+ const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
148
+ if (missing.length === 0) return
149
+
150
+ const newIds: Array<number | string> = []
151
+ for (const item of missing) {
152
+ const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
153
+ if (!invalidDimensionIds.value.has(id)) {
154
+ invalidDimensionIds.value.add(id)
155
+ newIds.push(id)
156
+ }
157
+ }
158
+ if (newIds.length > 0) {
159
+ const sample = newIds.slice(0, 10)
160
+ // eslint-disable-next-line no-console
161
+ console.warn(
162
+ '[Masonry] Items missing width/height detected:',
163
+ {
164
+ context,
165
+ count: newIds.length,
166
+ sampleIds: sample,
167
+ hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
168
+ }
169
+ )
170
+ }
171
+ } catch {
172
+ // best-effort diagnostics only
173
+ }
174
+ }
175
+
176
+ // Virtualization viewport state
177
+ const viewportTop = ref(0)
178
+ const viewportHeight = ref(0)
179
+ const VIRTUAL_BUFFER_PX = props.virtualBufferPx
180
+
181
+ // Gate transitions during virtualization-only DOM churn
182
+ const virtualizing = ref(false)
183
+
135
184
  // Scroll progress tracking
136
185
  const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
137
186
  distanceToTrigger: 0,
138
187
  isNearTrigger: false
139
188
  })
140
189
 
141
- const updateScrollProgress = () => {
190
+ const updateScrollProgress = (precomputedHeights?: number[]) => {
142
191
  if (!container.value) return
143
192
 
144
193
  const {scrollTop, clientHeight} = container.value
145
194
  const visibleBottom = scrollTop + clientHeight
146
195
 
147
- const columnHeights = calculateColumnHeights(masonry.value as any, columns.value)
148
- const longestColumn = Math.max(...columnHeights)
149
- const triggerPoint = longestColumn + 300
196
+ const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
197
+ const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
198
+ const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
199
+ const triggerPoint = threshold >= 0
200
+ ? Math.max(0, tallest - threshold)
201
+ : Math.max(0, tallest + threshold)
150
202
 
151
203
  const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
152
204
  const isNearTrigger = distanceToTrigger <= 100
@@ -158,7 +210,65 @@ const updateScrollProgress = () => {
158
210
  }
159
211
 
160
212
  // Setup composables
161
- const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry)
213
+ const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
214
+
215
+ // Transition wrappers that skip animation during virtualization
216
+ function enter(el: HTMLElement, done: () => void) {
217
+ if (virtualizing.value) {
218
+ const left = parseInt(el.dataset.left || '0', 10)
219
+ const top = parseInt(el.dataset.top || '0', 10)
220
+ el.style.transition = 'none'
221
+ el.style.opacity = '1'
222
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
223
+ el.style.removeProperty('--masonry-opacity-delay')
224
+ requestAnimationFrame(() => {
225
+ el.style.transition = ''
226
+ done()
227
+ })
228
+ } else {
229
+ onEnter(el, done)
230
+ }
231
+ }
232
+ function beforeEnter(el: HTMLElement) {
233
+ if (virtualizing.value) {
234
+ const left = parseInt(el.dataset.left || '0', 10)
235
+ const top = parseInt(el.dataset.top || '0', 10)
236
+ el.style.transition = 'none'
237
+ el.style.opacity = '1'
238
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
239
+ el.style.removeProperty('--masonry-opacity-delay')
240
+ } else {
241
+ onBeforeEnter(el)
242
+ }
243
+ }
244
+ function beforeLeave(el: HTMLElement) {
245
+ if (virtualizing.value) {
246
+ // no-op; removal will be immediate in leave
247
+ } else {
248
+ onBeforeLeave(el)
249
+ }
250
+ }
251
+ function leave(el: HTMLElement, done: () => void) {
252
+ if (virtualizing.value) {
253
+ // Skip animation during virtualization
254
+ done()
255
+ } else {
256
+ onLeave(el, done)
257
+ }
258
+ }
259
+
260
+ // Visible window of items (virtualization)
261
+ const visibleMasonry = computed(() => {
262
+ const top = viewportTop.value - VIRTUAL_BUFFER_PX
263
+ const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
264
+ const items = masonry.value as any[]
265
+ if (!items || items.length === 0) return [] as any[]
266
+ return items.filter((it: any) => {
267
+ const itemTop = it.top
268
+ const itemBottom = it.top + it.columnHeight
269
+ return itemBottom >= top && itemTop <= bottom
270
+ })
271
+ })
162
272
 
163
273
  const {handleScroll} = useMasonryScroll({
164
274
  container,
@@ -166,14 +276,13 @@ const {handleScroll} = useMasonryScroll({
166
276
  columns,
167
277
  containerHeight,
168
278
  isLoading,
169
- maxItems: props.maxItems,
170
279
  pageSize: props.pageSize,
171
280
  refreshLayout,
172
281
  setItemsRaw: (items: any[]) => {
173
282
  masonry.value = items
174
283
  },
175
284
  loadNext,
176
- leaveEstimateMs: props.leaveDurationMs
285
+ loadThresholdPx: props.loadThresholdPx
177
286
  })
178
287
 
179
288
  defineExpose({
@@ -182,11 +291,15 @@ defineExpose({
182
291
  containerHeight,
183
292
  remove,
184
293
  removeMany,
294
+ removeAll,
185
295
  loadNext,
186
296
  loadPage,
187
297
  reset,
188
298
  init,
189
- paginationHistory
299
+ paginationHistory,
300
+ cancelLoad,
301
+ scrollToTop,
302
+ totalItems: computed(() => (masonry.value as any[]).length)
190
303
  })
191
304
 
192
305
  function calculateHeight(content: any[]) {
@@ -201,6 +314,8 @@ function calculateHeight(content: any[]) {
201
314
 
202
315
  function refreshLayout(items: any[]) {
203
316
  if (!container.value) return
317
+ // Developer diagnostics: warn when dimensions are invalid
318
+ checkItemDimensions(items as any[], 'refreshLayout')
204
319
  const content = calculateLayout(items as any, container.value as HTMLElement, columns.value, layout.value as any)
205
320
  calculateHeight(content as any)
206
321
  masonry.value = content
@@ -212,6 +327,12 @@ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: nu
212
327
  const start = Date.now()
213
328
  onTick(total, total)
214
329
  const id = setInterval(() => {
330
+ // Check for cancellation
331
+ if (cancelRequested.value) {
332
+ clearInterval(id)
333
+ resolve()
334
+ return
335
+ }
215
336
  const elapsed = Date.now() - start
216
337
  const remaining = Math.max(0, total - elapsed)
217
338
  onTick(remaining, total)
@@ -262,11 +383,14 @@ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
262
383
  }
263
384
 
264
385
  async function loadPage(page: number) {
265
- if (isLoading.value) return
386
+ if (isLoading.value || cancelRequested.value) return
266
387
  isLoading.value = true
388
+ cancelRequested.value = false
267
389
  try {
268
390
  const baseline = (masonry.value as any[]).length
391
+ if (cancelRequested.value) return
269
392
  const response = await getContent(page)
393
+ if (cancelRequested.value) return
270
394
  paginationHistory.value.push(response.nextPage)
271
395
  await maybeBackfillToTarget(baseline)
272
396
  return response
@@ -279,12 +403,15 @@ async function loadPage(page: number) {
279
403
  }
280
404
 
281
405
  async function loadNext() {
282
- if (isLoading.value) return
406
+ if (isLoading.value || cancelRequested.value) return
283
407
  isLoading.value = true
408
+ cancelRequested.value = false
284
409
  try {
285
410
  const baseline = (masonry.value as any[]).length
411
+ if (cancelRequested.value) return
286
412
  const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
287
413
  const response = await getContent(currentPage)
414
+ if (cancelRequested.value) return
288
415
  paginationHistory.value.push(response.nextPage)
289
416
  await maybeBackfillToTarget(baseline)
290
417
  return response
@@ -300,9 +427,9 @@ async function remove(item: any) {
300
427
  const next = (masonry.value as any[]).filter(i => i.id !== item.id)
301
428
  masonry.value = next
302
429
  await nextTick()
303
- // Force a reflow so current transforms are committed
304
- void container.value?.offsetHeight
305
- // Start FLIP on next frame (single RAF)
430
+ // Commit DOM updates without forcing sync reflow
431
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
432
+ // Start FLIP on next frame
306
433
  requestAnimationFrame(() => {
307
434
  refreshLayout(next)
308
435
  })
@@ -314,24 +441,56 @@ async function removeMany(items: any[]) {
314
441
  const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
315
442
  masonry.value = next
316
443
  await nextTick()
317
- // Force a reflow so survivors' current transforms are committed
318
- void container.value?.offsetHeight
319
- // Start FLIP on next frame (single RAF)
444
+ // Commit DOM updates without forcing sync reflow
445
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
446
+ // Start FLIP on next frame
320
447
  requestAnimationFrame(() => {
321
448
  refreshLayout(next)
322
449
  })
323
450
  }
324
451
 
452
+ function scrollToTop(options?: ScrollToOptions) {
453
+ if (container.value) {
454
+ container.value.scrollTo({
455
+ top: 0,
456
+ behavior: options?.behavior ?? 'smooth',
457
+ ...options
458
+ })
459
+ }
460
+ }
461
+
462
+ async function removeAll() {
463
+ // Scroll to top first for better UX
464
+ scrollToTop({ behavior: 'smooth' })
465
+
466
+ // Clear all items
467
+ masonry.value = []
468
+
469
+ // Recalculate height to 0
470
+ containerHeight.value = 0
471
+
472
+ await nextTick()
473
+
474
+ // Emit completion event
475
+ emits('remove-all:complete')
476
+ }
477
+
325
478
  function onResize() {
326
479
  columns.value = getColumnCount(layout.value as any)
327
480
  refreshLayout(masonry.value as any)
481
+ if (container.value) {
482
+ viewportTop.value = container.value.scrollTop
483
+ viewportHeight.value = container.value.clientHeight
484
+ }
328
485
  }
329
486
 
330
487
  let backfillActive = false
488
+ const cancelRequested = ref(false)
331
489
 
332
490
  async function maybeBackfillToTarget(baselineCount: number) {
333
491
  if (!props.backfillEnabled) return
334
492
  if (backfillActive) return
493
+ if (cancelRequested.value) return
335
494
 
336
495
  const targetCount = (baselineCount || 0) + (props.pageSize || 0)
337
496
  if (!props.pageSize || props.pageSize <= 0) return
@@ -349,7 +508,8 @@ async function maybeBackfillToTarget(baselineCount: number) {
349
508
  while (
350
509
  (masonry.value as any[]).length < targetCount &&
351
510
  calls < props.backfillMaxCalls &&
352
- paginationHistory.value[paginationHistory.value.length - 1] != null
511
+ paginationHistory.value[paginationHistory.value.length - 1] != null &&
512
+ !cancelRequested.value
353
513
  ) {
354
514
  await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
355
515
  emits('backfill:tick', {
@@ -361,10 +521,13 @@ async function maybeBackfillToTarget(baselineCount: number) {
361
521
  })
362
522
  })
363
523
 
524
+ if (cancelRequested.value) break
525
+
364
526
  const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
365
527
  try {
366
528
  isLoading.value = true
367
529
  const response = await getContent(currentPage)
530
+ if (cancelRequested.value) break
368
531
  paginationHistory.value.push(response.nextPage)
369
532
  } finally {
370
533
  isLoading.value = false
@@ -379,7 +542,14 @@ async function maybeBackfillToTarget(baselineCount: number) {
379
542
  }
380
543
  }
381
544
 
545
+ function cancelLoad() {
546
+ cancelRequested.value = true
547
+ isLoading.value = false
548
+ backfillActive = false
549
+ }
550
+
382
551
  function reset() {
552
+ cancelLoad()
383
553
  if (container.value) {
384
554
  container.value.scrollTo({
385
555
  top: 0,
@@ -397,9 +567,20 @@ function reset() {
397
567
  }
398
568
  }
399
569
 
400
- const debouncedScrollHandler = debounce(() => {
401
- handleScroll()
402
- updateScrollProgress()
570
+ const debouncedScrollHandler = debounce(async () => {
571
+ if (container.value) {
572
+ viewportTop.value = container.value.scrollTop
573
+ viewportHeight.value = container.value.clientHeight
574
+ }
575
+ // Gate transitions for virtualization-only DOM changes
576
+ virtualizing.value = true
577
+ await nextTick()
578
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
579
+ virtualizing.value = false
580
+
581
+ const heights = calculateColumnHeights(masonry.value as any, columns.value)
582
+ handleScroll(heights as any)
583
+ updateScrollProgress(heights)
403
584
  }, 200)
404
585
 
405
586
  const debouncedResizeHandler = debounce(onResize, 200)
@@ -407,6 +588,8 @@ const debouncedResizeHandler = debounce(onResize, 200)
407
588
  function init(items: any[], page: any, next: any) {
408
589
  paginationHistory.value = [page]
409
590
  paginationHistory.value.push(next)
591
+ // Diagnostics: check incoming initial items
592
+ checkItemDimensions(items as any[], 'init')
410
593
  refreshLayout([...(masonry.value as any[]), ...items])
411
594
  updateScrollProgress()
412
595
  }
@@ -414,6 +597,10 @@ function init(items: any[], page: any, next: any) {
414
597
  onMounted(async () => {
415
598
  try {
416
599
  columns.value = getColumnCount(layout.value as any)
600
+ if (container.value) {
601
+ viewportTop.value = container.value.scrollTop
602
+ viewportHeight.value = container.value.clientHeight
603
+ }
417
604
 
418
605
  const initialPage = props.loadAtPage as any
419
606
  paginationHistory.value = [initialPage]
@@ -429,7 +616,7 @@ onMounted(async () => {
429
616
  isLoading.value = false
430
617
  }
431
618
 
432
- container.value?.addEventListener('scroll', debouncedScrollHandler)
619
+ container.value?.addEventListener('scroll', debouncedScrollHandler, { passive: true })
433
620
  window.addEventListener('resize', debouncedResizeHandler)
434
621
  })
435
622
 
@@ -443,14 +630,14 @@ onUnmounted(() => {
443
630
  <div class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }" ref="container">
444
631
  <div class="relative"
445
632
  :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
446
- <transition-group name="masonry" :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
447
- @leave="onLeave"
448
- @before-leave="onBeforeLeave">
449
- <div v-for="(item, i) in masonry" :key="`${item.page}-${item.id}`"
633
+ <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
634
+ @leave="leave"
635
+ @before-leave="beforeLeave">
636
+ <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
450
637
  class="absolute masonry-item"
451
638
  v-bind="getItemAttributes(item, i)">
452
639
  <slot name="item" v-bind="{item, remove}">
453
- <img :src="item.src" class="w-full"/>
640
+ <img :src="item.src" class="w-full" loading="lazy" decoding="async"/>
454
641
  <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
455
642
  @click="remove(item)">
456
643
  <i class="fas fa-trash"></i>