@duffcloudservices/cms 0.1.6 → 0.2.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.
@@ -1,13 +1,7 @@
1
1
  <template>
2
2
  <Teleport to="body">
3
- <div
4
- v-if="isVisible"
5
- class="dcs-ribbon-container"
6
- :class="{ 'dcs-ribbon-container--flipping': isFlipping }"
7
- :style="containerStyle"
8
- >
3
+ <div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
9
4
  <svg
10
- ref="ribbonSvgRef"
11
5
  :viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
12
6
  class="dcs-ribbon-svg"
13
7
  >
@@ -42,8 +36,7 @@
42
36
  fill="url(#dcs-ribbon-grad)"
43
37
  stroke="rgba(255,255,255,0.08)"
44
38
  stroke-width="1"
45
- :class="['dcs-ribbon-band', { 'dcs-ribbon-band--dragging': isDragging }]"
46
- @pointerdown.stop.prevent="onRibbonPointerDown"
39
+ class="dcs-ribbon-band"
47
40
  />
48
41
 
49
42
  <!-- Decorative stitched borders -->
@@ -72,6 +65,20 @@
72
65
  </textPath>
73
66
  </text>
74
67
  </svg>
68
+
69
+ <!--
70
+ Touch overlay: transparent HTML <div> sitting on top of the SVG.
71
+ Android Chrome ignores touch-action: none on SVG <path> elements
72
+ because the compositor only checks HTML elements in the hit chain.
73
+ This HTML div with clip-path creates a reliable touch-action: none
74
+ hit area over the ribbon band that works on all mobile browsers.
75
+ -->
76
+ <div
77
+ ref="touchOverlayRef"
78
+ :class="['dcs-ribbon-touch', { 'dcs-ribbon-touch--dragging': isDragging }]"
79
+ :style="touchClipStyle"
80
+ @pointerdown.stop.prevent="onPointerDown"
81
+ />
75
82
  </div>
76
83
  </Teleport>
77
84
  </template>
@@ -125,6 +132,13 @@ const STI = 4
125
132
  /** The preview domain where the ribbon should be displayed */
126
133
  const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
127
134
 
135
+ /**
136
+ * Query string parameter that suppresses the preview ribbon.
137
+ * Used by GitHub Actions snapshot capture so screenshots don't include the ribbon overlay.
138
+ * Example: https://preview.duffcloudservices.com/kept/?dcs-hide-ribbon
139
+ */
140
+ const HIDE_RIBBON_PARAM = 'dcs-hide-ribbon'
141
+
128
142
  /** localStorage key for persisting which corner the ribbon is docked to */
129
143
  const STORAGE_KEY = 'dcs-preview-ribbon-side'
130
144
 
@@ -149,6 +163,10 @@ function shouldShow(): boolean {
149
163
  return false
150
164
  }
151
165
 
166
+ // Allow suppression via query parameter (for automated screenshot capture)
167
+ const params = new URLSearchParams(globalThis.location.search)
168
+ if (params.has(HIDE_RIBBON_PARAM)) return false
169
+
152
170
  return true
153
171
  }
154
172
 
@@ -169,24 +187,6 @@ function saveSide(s: 'left' | 'right') {
169
187
  try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
170
188
  }
171
189
 
172
- /* ------------------------------------------------------------------ */
173
- /* Viewport tracking */
174
- /* ------------------------------------------------------------------ */
175
- const viewportWidth = ref(1920)
176
- const svgSizePx = ref(260)
177
-
178
- function updateViewport() {
179
- viewportWidth.value = window.innerWidth
180
- svgSizePx.value = window.innerWidth <= 640 ? 200 : 260
181
- }
182
-
183
- function onResize() {
184
- updateViewport()
185
- if (!isDragging.value && !isFlipping.value) {
186
- posX.value = getRestX()
187
- }
188
- }
189
-
190
190
  /* ------------------------------------------------------------------ */
191
191
  /* Version display */
192
192
  /* ------------------------------------------------------------------ */
@@ -212,10 +212,6 @@ onMounted(async () => {
212
212
  if (!isVisible.value) return
213
213
 
214
214
  side.value = loadSide()
215
- updateViewport()
216
- posX.value = getRestX()
217
-
218
- window.addEventListener('resize', onResize)
219
215
 
220
216
  // Resolve version
221
217
  if (resolvedVersion.value) return
@@ -253,239 +249,232 @@ onMounted(async () => {
253
249
  })
254
250
 
255
251
  /* ------------------------------------------------------------------ */
256
- /* Position state */
252
+ /* Elastic stretch state (control-point displacement) */
257
253
  /* ------------------------------------------------------------------ */
258
- const posX = ref(0)
259
- const posY = ref(0)
254
+ const stretchX = ref(0)
255
+ const stretchY = ref(0)
260
256
  const isDragging = ref(false)
261
- const isFlipping = ref(false)
262
257
 
263
- /** Rest X position for the current side */
264
- function getRestX(): number {
265
- if (side.value === 'left') return 0
266
- return viewportWidth.value - svgSizePx.value
267
- }
258
+ /**
259
+ * Absolute SVG-space control point for the band center-line.
260
+ * Left side default: (CP0, CP0) = (90, 90)
261
+ * Right side default: (SVG_SIZE - CP0, CP0) = (170, 90)
262
+ * stretchX/Y displace the control point in screen-mapped SVG units.
263
+ */
264
+ const cx = computed(() =>
265
+ side.value === 'left'
266
+ ? CP0 + stretchX.value
267
+ : SVG_SIZE - CP0 + stretchX.value,
268
+ )
269
+ const cy = computed(() => CP0 + stretchY.value)
268
270
 
269
- const containerStyle = computed(() => ({
270
- transform: `translate(${posX.value}px, ${posY.value}px)`,
271
- }))
271
+ /**
272
+ * Perpendicular sign: for left-side ribbon the outward perpendicular
273
+ * direction is (+1, +1); for right-side it is (-1, +1).
274
+ * This determines how CPO is applied to get outer/inner band edges.
275
+ */
276
+ const ps = computed(() => (side.value === 'left' ? 1 : -1))
272
277
 
273
278
  /* ------------------------------------------------------------------ */
274
- /* SVG path computation (side-aware, X-mirrored for right) */
279
+ /* SVG path computation (side-aware, with elastic control-point) */
275
280
  /* ------------------------------------------------------------------ */
276
281
 
277
282
  /**
278
- * Quadratic bezier curve for stitch / helper paths.
279
- * Left side: (0, y0) (x1, 0) via (cx, cy)
280
- * Right side: (SVG_SIZE, y0) → (SVG_SIZE-x1, 0) via (SVG_SIZE-cx, cy)
283
+ * Text center-line. On the right side the path direction is reversed
284
+ * so that textPath renders left-to-right (readable).
281
285
  */
282
- function mirrorQ(y0: number, cx: number, cy: number, x1: number): string {
283
- if (side.value === 'left') {
284
- return `M 0,${y0} Q ${cx},${cy} ${x1},0`
285
- }
286
- return `M ${SVG_SIZE},${y0} Q ${SVG_SIZE - cx},${cy} ${SVG_SIZE - x1},0`
287
- }
288
-
289
- /** Text center-line path. Reversed on right side so text reads naturally. */
290
286
  const textPath = computed(() => {
291
287
  if (side.value === 'left') {
292
- return `M 0,${EP} Q ${CP0},${CP0} ${EP},0`
288
+ return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
293
289
  }
294
- // Right side: path goes from top-edge right-edge for left-to-right text
295
- return `M ${SVG_SIZE - EP},0 Q ${SVG_SIZE - CP0},${CP0} ${SVG_SIZE},${EP}`
290
+ return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
296
291
  })
297
292
 
298
- const outerStitchPath = computed(() =>
299
- mirrorQ(
300
- EP + HW - STI,
301
- CP0 + CPO - 3,
302
- CP0 + CPO - 3,
303
- EP + HW - STI,
304
- ),
305
- )
306
-
307
- const innerStitchPath = computed(() =>
308
- mirrorQ(
309
- EP - HW + STI,
310
- CP0 - CPO + 3,
311
- CP0 - CPO + 3,
312
- EP - HW + STI,
313
- ),
314
- )
315
-
316
293
  const bandPath = computed(() => {
317
- const oY = EP + HW
318
- const oX = EP + HW
319
- const oCx = CP0 + CPO
320
- const oCy = CP0 + CPO
321
- const iY = EP - HW
322
- const iX = EP - HW
323
- const iCx = CP0 - CPO
324
- const iCy = CP0 - CPO
294
+ const p = ps.value
295
+ // Outer edge control point (further from corner)
296
+ const ocx = cx.value + p * CPO
297
+ const ocy = cy.value + CPO
298
+ // Inner edge control point (closer to corner)
299
+ const icx = cx.value - p * CPO
300
+ const icy = cy.value - CPO
325
301
 
326
302
  if (side.value === 'left') {
327
303
  return [
328
- `M 0,${oY} Q ${oCx},${oCy} ${oX},0`,
329
- `L ${iX},0`,
330
- `Q ${iCx},${iCy} 0,${iY}`,
304
+ `M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
305
+ `L ${EP - HW},0`,
306
+ `Q ${icx},${icy} 0,${EP - HW}`,
331
307
  `Z`,
332
308
  ].join(' ')
333
309
  }
334
310
 
335
311
  return [
336
- `M ${SVG_SIZE},${oY} Q ${SVG_SIZE - oCx},${oCy} ${SVG_SIZE - oX},0`,
337
- `L ${SVG_SIZE - iX},0`,
338
- `Q ${SVG_SIZE - iCx},${iCy} ${SVG_SIZE},${iY}`,
312
+ `M ${SVG_SIZE},${EP + HW} Q ${ocx},${ocy} ${SVG_SIZE - EP - HW},0`,
313
+ `L ${SVG_SIZE - EP + HW},0`,
314
+ `Q ${icx},${icy} ${SVG_SIZE},${EP - HW}`,
339
315
  `Z`,
340
316
  ].join(' ')
341
317
  })
342
318
 
319
+ const outerStitchPath = computed(() => {
320
+ const p = ps.value
321
+ const scx = cx.value + p * (CPO - 3)
322
+ const scy = cy.value + (CPO - 3)
323
+ const d = EP + HW - STI
324
+
325
+ if (side.value === 'left') {
326
+ return `M 0,${d} Q ${scx},${scy} ${d},0`
327
+ }
328
+ return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
329
+ })
330
+
331
+ const innerStitchPath = computed(() => {
332
+ const p = ps.value
333
+ const scx = cx.value - p * (CPO - 3)
334
+ const scy = cy.value - (CPO - 3)
335
+ const d = EP - HW + STI
336
+
337
+ if (side.value === 'left') {
338
+ return `M 0,${d} Q ${scx},${scy} ${d},0`
339
+ }
340
+ return `M ${SVG_SIZE},${d} Q ${scx},${scy} ${SVG_SIZE - d},0`
341
+ })
342
+
343
+ /* ------------------------------------------------------------------ */
344
+ /* Touch overlay clip-path */
345
+ /* ------------------------------------------------------------------ */
346
+
347
+ /**
348
+ * Clip-path polygon that creates an invisible hit area over the ribbon
349
+ * band, wider than the visible band for comfortable touch targeting.
350
+ * This is on an HTML <div>, so touch-action: none is guaranteed to work
351
+ * on Android Chrome (unlike SVG <path> elements).
352
+ */
353
+ const touchClipStyle = computed(() => {
354
+ if (side.value === 'left') {
355
+ // Quadrilateral covering the band diagonal: bottom-left → top-right
356
+ return { clipPath: 'polygon(0% 100%, 100% 0%, 65% 0%, 0% 65%)' }
357
+ }
358
+ // Right side: mirrored
359
+ return { clipPath: 'polygon(100% 100%, 0% 0%, 35% 0%, 100% 65%)' }
360
+ })
361
+
343
362
  /* ------------------------------------------------------------------ */
344
- /* Drag: translate ribbon + flip on midpoint crossing */
363
+ /* Drag: elastic control-point stretch + flip on midpoint crossing */
345
364
  /* ------------------------------------------------------------------ */
346
- const ribbonSvgRef = ref<SVGSVGElement | null>(null)
347
- let dragStart: { x: number; y: number; posX: number; posY: number } | null = null
348
- let capturedTarget: Element | null = null
349
- let lastPointerId: number | null = null
365
+ const touchOverlayRef = ref<HTMLDivElement | null>(null)
366
+ let dragStart: { x: number; y: number } | null = null
350
367
  let springRaf = 0
351
- let flipTimeout: ReturnType<typeof setTimeout> | null = null
352
368
 
353
- function onRibbonPointerDown(e: PointerEvent) {
354
- // Don't allow interaction during flip animation
355
- if (isFlipping.value) return
369
+ /**
370
+ * Get the rendered pixel size of the SVG element.
371
+ * Used to convert screen-space drag deltas to SVG-space units.
372
+ */
373
+ function getSvgPixelSize(): number {
374
+ return window.innerWidth <= 640 ? 200 : 260
375
+ }
356
376
 
377
+ function onPointerDown(e: PointerEvent) {
357
378
  // Cancel any in-progress spring animation
358
379
  if (springRaf) {
359
380
  cancelAnimationFrame(springRaf)
360
381
  springRaf = 0
361
382
  }
362
383
 
363
- // Capture the pointer on the element that received the event (the band path).
364
- // This ensures all subsequent pointer events are delivered to this element,
365
- // preventing the browser from cancelling the pointer sequence for scrolling.
366
- const target = e.currentTarget as Element
384
+ // Capture the pointer on the HTML overlay div.
385
+ // Unlike SVG <path> elements, HTML elements reliably support
386
+ // setPointerCapture on Android Chrome, ensuring all subsequent
387
+ // pointer events are delivered even when the finger moves outside.
388
+ const target = e.currentTarget as HTMLElement
367
389
  target.setPointerCapture(e.pointerId)
368
- capturedTarget = target
369
- lastPointerId = e.pointerId
370
390
 
371
391
  isDragging.value = true
372
- dragStart = { x: e.clientX, y: e.clientY, posX: posX.value, posY: posY.value }
392
+ dragStart = { x: e.clientX, y: e.clientY }
393
+
373
394
  globalThis.addEventListener('pointermove', onPointerMove)
374
395
  globalThis.addEventListener('pointerup', onPointerUp)
375
- globalThis.addEventListener('pointercancel', onPointerCancel)
396
+ globalThis.addEventListener('pointercancel', onPointerUp)
376
397
  }
377
398
 
378
399
  function onPointerMove(e: PointerEvent) {
379
400
  if (!dragStart) return
380
401
 
381
- const dx = e.clientX - dragStart.x
382
- const dy = e.clientY - dragStart.y
383
- posX.value = dragStart.posX + dx
384
- posY.value = dragStart.posY + dy
385
-
386
- // Check if ribbon center has crossed viewport midpoint → trigger flip
387
- const ribbonCenterX = posX.value + svgSizePx.value / 2
388
- const midpoint = viewportWidth.value / 2
389
-
390
- if (side.value === 'left' && ribbonCenterX > midpoint) {
391
- triggerFlip('right')
392
- } else if (side.value === 'right' && ribbonCenterX < midpoint) {
393
- triggerFlip('left')
402
+ // Convert screen-space drag delta to SVG-space units
403
+ const scale = SVG_SIZE / getSvgPixelSize()
404
+ stretchX.value = (e.clientX - dragStart.x) * scale
405
+ stretchY.value = (e.clientY - dragStart.y) * scale
406
+
407
+ // Flip when the pointer crosses the viewport midpoint
408
+ const mid = window.innerWidth / 2
409
+ if (side.value === 'left' && e.clientX > mid) {
410
+ triggerFlip('right', e)
411
+ } else if (side.value === 'right' && e.clientX < mid) {
412
+ triggerFlip('left', e)
394
413
  }
395
414
  }
396
415
 
397
- function onPointerUp(e?: PointerEvent) {
398
- releasePointer(e)
416
+ function onPointerUp(e: PointerEvent) {
417
+ if (touchOverlayRef.value) {
418
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
419
+ }
399
420
  isDragging.value = false
400
421
  dragStart = null
401
422
  removeListeners()
402
-
403
- // Spring back to rest position
404
423
  animateSnapBack()
405
424
  }
406
425
 
407
- /** Handle browser cancelling the pointer (e.g. when native scroll takes over) */
408
- function onPointerCancel(e: PointerEvent) {
409
- onPointerUp(e)
410
- }
411
-
412
- function releasePointer(e?: PointerEvent) {
413
- if (capturedTarget && e) {
414
- try { capturedTarget.releasePointerCapture(e.pointerId) } catch { /* already released */ }
415
- }
416
- capturedTarget = null
417
- lastPointerId = null
418
- }
419
-
420
426
  function removeListeners() {
421
427
  globalThis.removeEventListener('pointermove', onPointerMove)
422
428
  globalThis.removeEventListener('pointerup', onPointerUp)
423
- globalThis.removeEventListener('pointercancel', onPointerCancel)
429
+ globalThis.removeEventListener('pointercancel', onPointerUp)
424
430
  }
425
431
 
426
432
  /**
427
- * Animate the ribbon to the opposite corner.
428
- * Ends the drag, toggles SVG paths, and uses a CSS transition
429
- * to smoothly slide the container to its new rest position.
433
+ * Snap the ribbon to the opposite corner when the pointer crosses
434
+ * the viewport midpoint. Resets the elastic stretch, switches side,
435
+ * and persists the user's preference.
430
436
  */
431
- function triggerFlip(newSide: 'left' | 'right') {
437
+ function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
438
+ // Reset stretch to default shape
439
+ stretchX.value = 0
440
+ stretchY.value = 0
441
+
432
442
  // End drag
433
443
  isDragging.value = false
434
444
  dragStart = null
435
445
  removeListeners()
436
446
 
437
447
  // Release pointer capture
438
- if (capturedTarget && lastPointerId !== null) {
439
- try { capturedTarget.releasePointerCapture(lastPointerId) } catch { /* noop */ }
448
+ if (touchOverlayRef.value) {
449
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
440
450
  }
441
- capturedTarget = null
442
- lastPointerId = null
443
451
 
444
- // Toggle side (paths flip instantly, gradient direction updates)
452
+ // Switch to the new side
445
453
  side.value = newSide
446
454
  saveSide(newSide)
447
-
448
- // Enable CSS transition, then set target position.
449
- // The requestAnimationFrame ensures the browser has laid out the
450
- // current position before the transition target is applied.
451
- isFlipping.value = true
452
- requestAnimationFrame(() => {
453
- posX.value = getRestX()
454
- posY.value = 0
455
-
456
- flipTimeout = setTimeout(() => {
457
- isFlipping.value = false
458
- flipTimeout = null
459
- }, 550)
460
- })
461
455
  }
462
456
 
463
- /** Spring-physics animation to snap the ribbon back to its rest position */
457
+ /** Spring-physics animation to snap the control point back to default */
464
458
  function animateSnapBack() {
465
- const targetX = getRestX()
466
- const targetY = 0
467
459
  const stiffness = 0.12
468
460
  const damping = 0.72
469
461
  let vx = 0
470
462
  let vy = 0
471
463
 
472
464
  function step() {
473
- const dxToTarget = posX.value - targetX
474
- const dyToTarget = posY.value - targetY
475
-
476
- vx = (vx - stiffness * dxToTarget) * damping
477
- vy = (vy - stiffness * dyToTarget) * damping
478
- posX.value += vx
479
- posY.value += vy
465
+ vx = (vx - stiffness * stretchX.value) * damping
466
+ vy = (vy - stiffness * stretchY.value) * damping
467
+ stretchX.value += vx
468
+ stretchY.value += vy
480
469
 
481
470
  if (
482
- Math.abs(dxToTarget) < 0.3 &&
483
- Math.abs(dyToTarget) < 0.3 &&
471
+ Math.abs(stretchX.value) < 0.3 &&
472
+ Math.abs(stretchY.value) < 0.3 &&
484
473
  Math.abs(vx) < 0.3 &&
485
474
  Math.abs(vy) < 0.3
486
475
  ) {
487
- posX.value = targetX
488
- posY.value = targetY
476
+ stretchX.value = 0
477
+ stretchY.value = 0
489
478
  springRaf = 0
490
479
  return
491
480
  }
@@ -501,31 +490,31 @@ function animateSnapBack() {
501
490
  /* ------------------------------------------------------------------ */
502
491
  onBeforeUnmount(() => {
503
492
  if (springRaf) cancelAnimationFrame(springRaf)
504
- if (flipTimeout) clearTimeout(flipTimeout)
505
- globalThis.removeEventListener('pointermove', onPointerMove)
506
- globalThis.removeEventListener('pointerup', onPointerUp)
507
- globalThis.removeEventListener('pointercancel', onPointerCancel)
508
- window.removeEventListener('resize', onResize)
493
+ removeListeners()
509
494
  })
510
495
  </script>
511
496
 
512
497
  <style scoped>
513
498
  /* ------------------------------------------------------------------ */
514
- /* Container — fixed top-left, non-blocking, positioned via transform */
499
+ /* Container — fixed in corner, non-blocking */
515
500
  /* ------------------------------------------------------------------ */
516
501
  .dcs-ribbon-container {
517
502
  position: fixed;
518
503
  top: 0;
519
- left: 0;
520
504
  z-index: 2147483647;
521
505
  pointer-events: none;
522
506
  user-select: none;
523
507
  -webkit-user-select: none;
524
508
  }
525
509
 
526
- /* Smooth slide transition when snapping to the opposite corner */
527
- .dcs-ribbon-container--flipping {
528
- transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
510
+ .dcs-ribbon-container--left {
511
+ left: 0;
512
+ right: auto;
513
+ }
514
+
515
+ .dcs-ribbon-container--right {
516
+ right: 0;
517
+ left: auto;
529
518
  }
530
519
 
531
520
  /* ------------------------------------------------------------------ */
@@ -540,16 +529,10 @@ onBeforeUnmount(() => {
540
529
  }
541
530
 
542
531
  /* ------------------------------------------------------------------ */
543
- /* Band (interactive) */
532
+ /* Band (visual only — touch handled by overlay div) */
544
533
  /* ------------------------------------------------------------------ */
545
534
  .dcs-ribbon-band {
546
- pointer-events: auto;
547
- cursor: grab;
548
- touch-action: none;
549
- }
550
-
551
- .dcs-ribbon-band--dragging {
552
- cursor: grabbing;
535
+ pointer-events: none;
553
536
  }
554
537
 
555
538
  /* ------------------------------------------------------------------ */
@@ -584,12 +567,41 @@ onBeforeUnmount(() => {
584
567
  display: none;
585
568
  }
586
569
 
570
+ /* ------------------------------------------------------------------ */
571
+ /* Touch overlay — transparent HTML div for reliable mobile touch */
572
+ /* */
573
+ /* Android Chrome ignores touch-action on SVG <path> elements because */
574
+ /* the compositor only walks HTML elements. This HTML <div> with */
575
+ /* clip-path creates a shaped, invisible hit area where touch-action */
576
+ /* is guaranteed to work. */
577
+ /* ------------------------------------------------------------------ */
578
+ .dcs-ribbon-touch {
579
+ position: absolute;
580
+ top: 0;
581
+ left: 0;
582
+ width: 260px;
583
+ height: 260px;
584
+ touch-action: none;
585
+ pointer-events: auto;
586
+ cursor: grab;
587
+ -webkit-tap-highlight-color: transparent;
588
+ /* clip-path set via :style binding for left/right side */
589
+ }
590
+
591
+ .dcs-ribbon-touch--dragging {
592
+ cursor: grabbing;
593
+ }
594
+
587
595
  /* Compact label for narrow screens */
588
596
  @media (max-width: 640px) {
589
597
  .dcs-ribbon-svg {
590
598
  width: 200px;
591
599
  height: 200px;
592
600
  }
601
+ .dcs-ribbon-touch {
602
+ width: 200px;
603
+ height: 200px;
604
+ }
593
605
  .dcs-ribbon-text--full {
594
606
  display: none;
595
607
  }
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Convenience component that renders a responsive `<picture>` element for
4
+ * DCS CDN-hosted images. For non-CDN images it falls back to a plain `<img>`.
5
+ *
6
+ * @example
7
+ * ```vue
8
+ * <ResponsiveImage
9
+ * src="https://files.duffcloudservices.com/kept/assets/hero/abc-123.jpg"
10
+ * alt="Hero banner"
11
+ * context="hero"
12
+ * class="w-full h-full object-cover"
13
+ * />
14
+ * ```
15
+ */
16
+ import { useResponsiveImage } from '../composables/useResponsiveImage'
17
+ import type { ImageContext } from '@duffcloudservices/cms-core'
18
+
19
+ const props = defineProps<{
20
+ /** Image source URL (original CDN URL or local path). */
21
+ src: string
22
+ /** Alt text for accessibility. */
23
+ alt: string
24
+ /** Sizing context — determines which variants to include. */
25
+ context?: ImageContext
26
+ /** CSS class(es) applied to the `<img>` element. */
27
+ class?: string
28
+ /** Optional `sizes` attribute override. */
29
+ sizes?: string
30
+ /** Skip responsive variants and use the original URL only. */
31
+ original?: boolean
32
+ }>()
33
+
34
+ const image = useResponsiveImage({
35
+ src: () => props.src,
36
+ alt: () => props.alt,
37
+ context: () => props.context ?? 'content',
38
+ sizes: () => props.sizes,
39
+ original: () => props.original,
40
+ })
41
+ </script>
42
+
43
+ <template>
44
+ <picture v-if="image.hasVariants && !original">
45
+ <source
46
+ v-for="source in image.sources"
47
+ :key="source.type"
48
+ :srcset="source.srcset"
49
+ :type="source.type"
50
+ :sizes="source.sizes"
51
+ />
52
+ <img v-bind="image.imgProps" :class="props.class" />
53
+ </picture>
54
+ <img v-else v-bind="image.imgProps" :class="props.class" />
55
+ </template>