@duffcloudservices/cms 0.3.12 → 0.3.14

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,613 +1,613 @@
1
- <template>
2
- <Teleport to="body">
3
- <div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
4
- <svg
5
- :viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
6
- class="dcs-ribbon-svg"
7
- >
8
- <defs>
9
- <path id="dcs-ribbon-text-path" :d="textPath" />
10
- <linearGradient
11
- id="dcs-ribbon-grad"
12
- :x1="side === 'left' ? '0%' : '100%'"
13
- y1="100%"
14
- :x2="side === 'left' ? '100%' : '0%'"
15
- y2="0%"
16
- >
17
- <stop offset="0%" stop-color="#1a1a2e" />
18
- <stop offset="40%" stop-color="#16213e" />
19
- <stop offset="100%" stop-color="#0f3460" />
20
- </linearGradient>
21
- <filter id="dcs-ribbon-ts">
22
- <feDropShadow dx="0" dy="1" stdDeviation="0.8" flood-color="rgba(0,0,0,0.5)" />
23
- </filter>
24
- </defs>
25
-
26
- <!-- Drop shadow (offset copy of band) -->
27
- <path
28
- :d="bandPath"
29
- fill="rgba(0,0,0,0.2)"
30
- :transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
31
- />
32
-
33
- <!-- Main ribbon band -->
34
- <path
35
- :d="bandPath"
36
- fill="url(#dcs-ribbon-grad)"
37
- stroke="rgba(255,255,255,0.08)"
38
- stroke-width="1"
39
- class="dcs-ribbon-band"
40
- />
41
-
42
- <!-- Decorative stitched borders -->
43
- <path :d="outerStitchPath" class="dcs-ribbon-stitch" />
44
- <path :d="innerStitchPath" class="dcs-ribbon-stitch" />
45
-
46
- <!-- Text: full (wide screens) -->
47
- <text
48
- class="dcs-ribbon-text dcs-ribbon-text--full"
49
- dominant-baseline="central"
50
- filter="url(#dcs-ribbon-ts)"
51
- >
52
- <textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
53
- Duff Cloud Services Preview Site{{ displayVersion }}
54
- </textPath>
55
- </text>
56
-
57
- <!-- Text: compact (narrow screens) -->
58
- <text
59
- class="dcs-ribbon-text dcs-ribbon-text--compact"
60
- dominant-baseline="central"
61
- filter="url(#dcs-ribbon-ts)"
62
- >
63
- <textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
64
- DCS Preview{{ displayVersion }}
65
- </textPath>
66
- </text>
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
- />
82
- </div>
83
- </Teleport>
84
- </template>
85
-
86
- <script setup lang="ts">
87
- import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
88
-
89
- /* ------------------------------------------------------------------ */
90
- /* Props */
91
- /* ------------------------------------------------------------------ */
92
- const props = withDefaults(
93
- defineProps<{
94
- /** Override the auto-detected version string (e.g. "1.2.3"). */
95
- version?: string | null
96
- }>(),
97
- {
98
- version: null,
99
- },
100
- )
101
-
102
- /* ------------------------------------------------------------------ */
103
- /* Geometry constants */
104
- /* ------------------------------------------------------------------ */
105
-
106
- /** SVG viewBox size (square, covers the ribbon corner area) */
107
- const SVG_SIZE = 260
108
-
109
- /** Where the ribbon center-line meets each viewport edge */
110
- const EP = 220
111
-
112
- /** Half the ribbon band width (perpendicular to curve) */
113
- const HW = 20
114
-
115
- /** Default bezier control-point position (x = y → symmetric 45° curve) */
116
- const CP0 = 90
117
-
118
- /**
119
- * Perpendicular offset for band edges relative to center control point.
120
- * HW / √2 because the perpendicular to a 45° diagonal has (1/√2, 1/√2)
121
- * components.
122
- */
123
- const CPO = Math.round(HW / Math.SQRT2) // ≈ 14
124
-
125
- /** Stitch-line inset from band edges (px in SVG space) */
126
- const STI = 4
127
-
128
- /* ------------------------------------------------------------------ */
129
- /* Constants */
130
- /* ------------------------------------------------------------------ */
131
-
132
- /** The preview domain where the ribbon should be displayed */
133
- const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
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
-
142
- /** localStorage key for persisting which corner the ribbon is docked to */
143
- const STORAGE_KEY = 'dcs-preview-ribbon-side'
144
-
145
- /* ------------------------------------------------------------------ */
146
- /* Visibility gate */
147
- /* ------------------------------------------------------------------ */
148
- const isVisible = ref(false)
149
-
150
- function shouldShow(): boolean {
151
- if (globalThis.window === undefined) return false
152
-
153
- const host = globalThis.location.hostname
154
-
155
- // Only show on the preview domain
156
- if (host !== PREVIEW_DOMAIN) return false
157
-
158
- // Never show in the visual page editor (iframe inside portal)
159
- try {
160
- if (globalThis.self !== globalThis.top) return false
161
- } catch {
162
- // cross-origin – inside an iframe → editor context
163
- return false
164
- }
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
-
170
- return true
171
- }
172
-
173
- /* ------------------------------------------------------------------ */
174
- /* Side state (which corner the ribbon is docked to, persisted) */
175
- /* ------------------------------------------------------------------ */
176
- const side = ref<'left' | 'right'>('left')
177
-
178
- function loadSide(): 'left' | 'right' {
179
- try {
180
- const s = localStorage.getItem(STORAGE_KEY)
181
- if (s === 'left' || s === 'right') return s
182
- } catch { /* noop */ }
183
- return 'left'
184
- }
185
-
186
- function saveSide(s: 'left' | 'right') {
187
- try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
188
- }
189
-
190
- /* ------------------------------------------------------------------ */
191
- /* Version display */
192
- /* ------------------------------------------------------------------ */
193
- const resolvedVersion = ref<string | null>(props.version ?? null)
194
-
195
- const displayVersion = computed(() =>
196
- resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
197
- )
198
-
199
- // Sync prop changes
200
- watch(
201
- () => props.version,
202
- (v) => {
203
- if (v !== null && v !== undefined) resolvedVersion.value = v
204
- },
205
- )
206
-
207
- /* ------------------------------------------------------------------ */
208
- /* Lifecycle */
209
- /* ------------------------------------------------------------------ */
210
- onMounted(async () => {
211
- isVisible.value = shouldShow()
212
- if (!isVisible.value) return
213
-
214
- side.value = loadSide()
215
-
216
- // Resolve version
217
- if (resolvedVersion.value) return
218
-
219
- try {
220
- const envVersion = (import.meta.env as Record<string, string | undefined>)
221
- .VITE_SITE_VERSION
222
- if (envVersion) {
223
- resolvedVersion.value = envVersion
224
- return
225
- }
226
- } catch {
227
- /* noop */
228
- }
229
-
230
- try {
231
- const slug =
232
- (import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
233
- ''
234
- if (!slug) return
235
- const base =
236
- (import.meta.env as Record<string, string | undefined>)
237
- .VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
238
- const res = await fetch(
239
- `${base}/api/v1/sites/${slug}/release-notes/latest`,
240
- { headers: { Accept: 'application/json' } },
241
- )
242
- if (res.ok) {
243
- const data = await res.json()
244
- if (data.version) resolvedVersion.value = data.version
245
- }
246
- } catch {
247
- /* non-critical */
248
- }
249
- })
250
-
251
- /* ------------------------------------------------------------------ */
252
- /* Elastic stretch state (control-point displacement) */
253
- /* ------------------------------------------------------------------ */
254
- const stretchX = ref(0)
255
- const stretchY = ref(0)
256
- const isDragging = ref(false)
257
-
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)
270
-
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))
277
-
278
- /* ------------------------------------------------------------------ */
279
- /* SVG path computation (side-aware, with elastic control-point) */
280
- /* ------------------------------------------------------------------ */
281
-
282
- /**
283
- * Text center-line. On the right side the path direction is reversed
284
- * so that textPath renders left-to-right (readable).
285
- */
286
- const textPath = computed(() => {
287
- if (side.value === 'left') {
288
- return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
289
- }
290
- return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
291
- })
292
-
293
- const bandPath = computed(() => {
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
301
-
302
- if (side.value === 'left') {
303
- return [
304
- `M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
305
- `L ${EP - HW},0`,
306
- `Q ${icx},${icy} 0,${EP - HW}`,
307
- `Z`,
308
- ].join(' ')
309
- }
310
-
311
- return [
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}`,
315
- `Z`,
316
- ].join(' ')
317
- })
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
-
362
- /* ------------------------------------------------------------------ */
363
- /* Drag: elastic control-point stretch + flip on midpoint crossing */
364
- /* ------------------------------------------------------------------ */
365
- const touchOverlayRef = ref<HTMLDivElement | null>(null)
366
- let dragStart: { x: number; y: number } | null = null
367
- let springRaf = 0
368
-
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
- }
376
-
377
- function onPointerDown(e: PointerEvent) {
378
- // Cancel any in-progress spring animation
379
- if (springRaf) {
380
- cancelAnimationFrame(springRaf)
381
- springRaf = 0
382
- }
383
-
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
389
- target.setPointerCapture(e.pointerId)
390
-
391
- isDragging.value = true
392
- dragStart = { x: e.clientX, y: e.clientY }
393
-
394
- globalThis.addEventListener('pointermove', onPointerMove)
395
- globalThis.addEventListener('pointerup', onPointerUp)
396
- globalThis.addEventListener('pointercancel', onPointerUp)
397
- }
398
-
399
- function onPointerMove(e: PointerEvent) {
400
- if (!dragStart) return
401
-
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)
413
- }
414
- }
415
-
416
- function onPointerUp(e: PointerEvent) {
417
- if (touchOverlayRef.value) {
418
- try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
419
- }
420
- isDragging.value = false
421
- dragStart = null
422
- removeListeners()
423
- animateSnapBack()
424
- }
425
-
426
- function removeListeners() {
427
- globalThis.removeEventListener('pointermove', onPointerMove)
428
- globalThis.removeEventListener('pointerup', onPointerUp)
429
- globalThis.removeEventListener('pointercancel', onPointerUp)
430
- }
431
-
432
- /**
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.
436
- */
437
- function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
438
- // Reset stretch to default shape
439
- stretchX.value = 0
440
- stretchY.value = 0
441
-
442
- // End drag
443
- isDragging.value = false
444
- dragStart = null
445
- removeListeners()
446
-
447
- // Release pointer capture
448
- if (touchOverlayRef.value) {
449
- try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
450
- }
451
-
452
- // Switch to the new side
453
- side.value = newSide
454
- saveSide(newSide)
455
- }
456
-
457
- /** Spring-physics animation to snap the control point back to default */
458
- function animateSnapBack() {
459
- const stiffness = 0.12
460
- const damping = 0.72
461
- let vx = 0
462
- let vy = 0
463
-
464
- function step() {
465
- vx = (vx - stiffness * stretchX.value) * damping
466
- vy = (vy - stiffness * stretchY.value) * damping
467
- stretchX.value += vx
468
- stretchY.value += vy
469
-
470
- if (
471
- Math.abs(stretchX.value) < 0.3 &&
472
- Math.abs(stretchY.value) < 0.3 &&
473
- Math.abs(vx) < 0.3 &&
474
- Math.abs(vy) < 0.3
475
- ) {
476
- stretchX.value = 0
477
- stretchY.value = 0
478
- springRaf = 0
479
- return
480
- }
481
-
482
- springRaf = requestAnimationFrame(step)
483
- }
484
-
485
- springRaf = requestAnimationFrame(step)
486
- }
487
-
488
- /* ------------------------------------------------------------------ */
489
- /* Cleanup */
490
- /* ------------------------------------------------------------------ */
491
- onBeforeUnmount(() => {
492
- if (springRaf) cancelAnimationFrame(springRaf)
493
- removeListeners()
494
- })
495
- </script>
496
-
497
- <style scoped>
498
- /* ------------------------------------------------------------------ */
499
- /* Container — fixed in corner, non-blocking */
500
- /* ------------------------------------------------------------------ */
501
- .dcs-ribbon-container {
502
- position: fixed;
503
- top: 0;
504
- z-index: 2147483647;
505
- pointer-events: none;
506
- user-select: none;
507
- -webkit-user-select: none;
508
- }
509
-
510
- .dcs-ribbon-container--left {
511
- left: 0;
512
- right: auto;
513
- }
514
-
515
- .dcs-ribbon-container--right {
516
- right: 0;
517
- left: auto;
518
- }
519
-
520
- /* ------------------------------------------------------------------ */
521
- /* SVG canvas */
522
- /* ------------------------------------------------------------------ */
523
- .dcs-ribbon-svg {
524
- display: block;
525
- width: 260px;
526
- height: 260px;
527
- overflow: visible;
528
- pointer-events: none;
529
- }
530
-
531
- /* ------------------------------------------------------------------ */
532
- /* Band (visual only — touch handled by overlay div) */
533
- /* ------------------------------------------------------------------ */
534
- .dcs-ribbon-band {
535
- pointer-events: none;
536
- }
537
-
538
- /* ------------------------------------------------------------------ */
539
- /* Stitched borders */
540
- /* ------------------------------------------------------------------ */
541
- .dcs-ribbon-stitch {
542
- fill: none;
543
- stroke: rgba(255, 255, 255, 0.15);
544
- stroke-width: 1;
545
- stroke-dasharray: 4 3;
546
- pointer-events: none;
547
- }
548
-
549
- /* ------------------------------------------------------------------ */
550
- /* Text */
551
- /* ------------------------------------------------------------------ */
552
- .dcs-ribbon-text {
553
- fill: #e0e7ff;
554
- font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
555
- font-weight: 600;
556
- font-size: 12px;
557
- letter-spacing: 0.06em;
558
- text-transform: uppercase;
559
- pointer-events: none;
560
- }
561
-
562
- /* Full label for wide screens */
563
- .dcs-ribbon-text--full {
564
- display: block;
565
- }
566
- .dcs-ribbon-text--compact {
567
- display: none;
568
- }
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
-
595
- /* Compact label for narrow screens */
596
- @media (max-width: 640px) {
597
- .dcs-ribbon-svg {
598
- width: 200px;
599
- height: 200px;
600
- }
601
- .dcs-ribbon-touch {
602
- width: 200px;
603
- height: 200px;
604
- }
605
- .dcs-ribbon-text--full {
606
- display: none;
607
- }
608
- .dcs-ribbon-text--compact {
609
- display: block;
610
- font-size: 11px;
611
- }
612
- }
1
+ <template>
2
+ <Teleport to="body">
3
+ <div v-if="isVisible" :class="['dcs-ribbon-container', `dcs-ribbon-container--${side}`]">
4
+ <svg
5
+ :viewBox="`0 0 ${SVG_SIZE} ${SVG_SIZE}`"
6
+ class="dcs-ribbon-svg"
7
+ >
8
+ <defs>
9
+ <path id="dcs-ribbon-text-path" :d="textPath" />
10
+ <linearGradient
11
+ id="dcs-ribbon-grad"
12
+ :x1="side === 'left' ? '0%' : '100%'"
13
+ y1="100%"
14
+ :x2="side === 'left' ? '100%' : '0%'"
15
+ y2="0%"
16
+ >
17
+ <stop offset="0%" stop-color="#1a1a2e" />
18
+ <stop offset="40%" stop-color="#16213e" />
19
+ <stop offset="100%" stop-color="#0f3460" />
20
+ </linearGradient>
21
+ <filter id="dcs-ribbon-ts">
22
+ <feDropShadow dx="0" dy="1" stdDeviation="0.8" flood-color="rgba(0,0,0,0.5)" />
23
+ </filter>
24
+ </defs>
25
+
26
+ <!-- Drop shadow (offset copy of band) -->
27
+ <path
28
+ :d="bandPath"
29
+ fill="rgba(0,0,0,0.2)"
30
+ :transform="side === 'left' ? 'translate(2,3)' : 'translate(-2,3)'"
31
+ />
32
+
33
+ <!-- Main ribbon band -->
34
+ <path
35
+ :d="bandPath"
36
+ fill="url(#dcs-ribbon-grad)"
37
+ stroke="rgba(255,255,255,0.08)"
38
+ stroke-width="1"
39
+ class="dcs-ribbon-band"
40
+ />
41
+
42
+ <!-- Decorative stitched borders -->
43
+ <path :d="outerStitchPath" class="dcs-ribbon-stitch" />
44
+ <path :d="innerStitchPath" class="dcs-ribbon-stitch" />
45
+
46
+ <!-- Text: full (wide screens) -->
47
+ <text
48
+ class="dcs-ribbon-text dcs-ribbon-text--full"
49
+ dominant-baseline="central"
50
+ filter="url(#dcs-ribbon-ts)"
51
+ >
52
+ <textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
53
+ Duff Cloud Services Preview Site{{ displayVersion }}
54
+ </textPath>
55
+ </text>
56
+
57
+ <!-- Text: compact (narrow screens) -->
58
+ <text
59
+ class="dcs-ribbon-text dcs-ribbon-text--compact"
60
+ dominant-baseline="central"
61
+ filter="url(#dcs-ribbon-ts)"
62
+ >
63
+ <textPath href="#dcs-ribbon-text-path" startOffset="50%" text-anchor="middle">
64
+ DCS Preview{{ displayVersion }}
65
+ </textPath>
66
+ </text>
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
+ />
82
+ </div>
83
+ </Teleport>
84
+ </template>
85
+
86
+ <script setup lang="ts">
87
+ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
88
+
89
+ /* ------------------------------------------------------------------ */
90
+ /* Props */
91
+ /* ------------------------------------------------------------------ */
92
+ const props = withDefaults(
93
+ defineProps<{
94
+ /** Override the auto-detected version string (e.g. "1.2.3"). */
95
+ version?: string | null
96
+ }>(),
97
+ {
98
+ version: null,
99
+ },
100
+ )
101
+
102
+ /* ------------------------------------------------------------------ */
103
+ /* Geometry constants */
104
+ /* ------------------------------------------------------------------ */
105
+
106
+ /** SVG viewBox size (square, covers the ribbon corner area) */
107
+ const SVG_SIZE = 260
108
+
109
+ /** Where the ribbon center-line meets each viewport edge */
110
+ const EP = 220
111
+
112
+ /** Half the ribbon band width (perpendicular to curve) */
113
+ const HW = 20
114
+
115
+ /** Default bezier control-point position (x = y → symmetric 45° curve) */
116
+ const CP0 = 90
117
+
118
+ /**
119
+ * Perpendicular offset for band edges relative to center control point.
120
+ * HW / √2 because the perpendicular to a 45° diagonal has (1/√2, 1/√2)
121
+ * components.
122
+ */
123
+ const CPO = Math.round(HW / Math.SQRT2) // ≈ 14
124
+
125
+ /** Stitch-line inset from band edges (px in SVG space) */
126
+ const STI = 4
127
+
128
+ /* ------------------------------------------------------------------ */
129
+ /* Constants */
130
+ /* ------------------------------------------------------------------ */
131
+
132
+ /** The preview domain where the ribbon should be displayed */
133
+ const PREVIEW_DOMAIN = 'preview.duffcloudservices.com'
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
+
142
+ /** localStorage key for persisting which corner the ribbon is docked to */
143
+ const STORAGE_KEY = 'dcs-preview-ribbon-side'
144
+
145
+ /* ------------------------------------------------------------------ */
146
+ /* Visibility gate */
147
+ /* ------------------------------------------------------------------ */
148
+ const isVisible = ref(false)
149
+
150
+ function shouldShow(): boolean {
151
+ if (globalThis.window === undefined) return false
152
+
153
+ const host = globalThis.location.hostname
154
+
155
+ // Only show on the preview domain
156
+ if (host !== PREVIEW_DOMAIN) return false
157
+
158
+ // Never show in the visual page editor (iframe inside portal)
159
+ try {
160
+ if (globalThis.self !== globalThis.top) return false
161
+ } catch {
162
+ // cross-origin – inside an iframe → editor context
163
+ return false
164
+ }
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
+
170
+ return true
171
+ }
172
+
173
+ /* ------------------------------------------------------------------ */
174
+ /* Side state (which corner the ribbon is docked to, persisted) */
175
+ /* ------------------------------------------------------------------ */
176
+ const side = ref<'left' | 'right'>('left')
177
+
178
+ function loadSide(): 'left' | 'right' {
179
+ try {
180
+ const s = localStorage.getItem(STORAGE_KEY)
181
+ if (s === 'left' || s === 'right') return s
182
+ } catch { /* noop */ }
183
+ return 'left'
184
+ }
185
+
186
+ function saveSide(s: 'left' | 'right') {
187
+ try { localStorage.setItem(STORAGE_KEY, s) } catch { /* noop */ }
188
+ }
189
+
190
+ /* ------------------------------------------------------------------ */
191
+ /* Version display */
192
+ /* ------------------------------------------------------------------ */
193
+ const resolvedVersion = ref<string | null>(props.version ?? null)
194
+
195
+ const displayVersion = computed(() =>
196
+ resolvedVersion.value ? ` v${resolvedVersion.value}` : '',
197
+ )
198
+
199
+ // Sync prop changes
200
+ watch(
201
+ () => props.version,
202
+ (v) => {
203
+ if (v !== null && v !== undefined) resolvedVersion.value = v
204
+ },
205
+ )
206
+
207
+ /* ------------------------------------------------------------------ */
208
+ /* Lifecycle */
209
+ /* ------------------------------------------------------------------ */
210
+ onMounted(async () => {
211
+ isVisible.value = shouldShow()
212
+ if (!isVisible.value) return
213
+
214
+ side.value = loadSide()
215
+
216
+ // Resolve version
217
+ if (resolvedVersion.value) return
218
+
219
+ try {
220
+ const envVersion = (import.meta.env as Record<string, string | undefined>)
221
+ .VITE_SITE_VERSION
222
+ if (envVersion) {
223
+ resolvedVersion.value = envVersion
224
+ return
225
+ }
226
+ } catch {
227
+ /* noop */
228
+ }
229
+
230
+ try {
231
+ const slug =
232
+ (import.meta.env as Record<string, string | undefined>).VITE_SITE_SLUG ??
233
+ ''
234
+ if (!slug) return
235
+ const base =
236
+ (import.meta.env as Record<string, string | undefined>)
237
+ .VITE_API_BASE_URL ?? 'https://portal.duffcloudservices.com'
238
+ const res = await fetch(
239
+ `${base}/api/v1/sites/${slug}/release-notes/latest`,
240
+ { headers: { Accept: 'application/json' } },
241
+ )
242
+ if (res.ok) {
243
+ const data = await res.json()
244
+ if (data.version) resolvedVersion.value = data.version
245
+ }
246
+ } catch {
247
+ /* non-critical */
248
+ }
249
+ })
250
+
251
+ /* ------------------------------------------------------------------ */
252
+ /* Elastic stretch state (control-point displacement) */
253
+ /* ------------------------------------------------------------------ */
254
+ const stretchX = ref(0)
255
+ const stretchY = ref(0)
256
+ const isDragging = ref(false)
257
+
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)
270
+
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))
277
+
278
+ /* ------------------------------------------------------------------ */
279
+ /* SVG path computation (side-aware, with elastic control-point) */
280
+ /* ------------------------------------------------------------------ */
281
+
282
+ /**
283
+ * Text center-line. On the right side the path direction is reversed
284
+ * so that textPath renders left-to-right (readable).
285
+ */
286
+ const textPath = computed(() => {
287
+ if (side.value === 'left') {
288
+ return `M 0,${EP} Q ${cx.value},${cy.value} ${EP},0`
289
+ }
290
+ return `M ${SVG_SIZE - EP},0 Q ${cx.value},${cy.value} ${SVG_SIZE},${EP}`
291
+ })
292
+
293
+ const bandPath = computed(() => {
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
301
+
302
+ if (side.value === 'left') {
303
+ return [
304
+ `M 0,${EP + HW} Q ${ocx},${ocy} ${EP + HW},0`,
305
+ `L ${EP - HW},0`,
306
+ `Q ${icx},${icy} 0,${EP - HW}`,
307
+ `Z`,
308
+ ].join(' ')
309
+ }
310
+
311
+ return [
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}`,
315
+ `Z`,
316
+ ].join(' ')
317
+ })
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
+
362
+ /* ------------------------------------------------------------------ */
363
+ /* Drag: elastic control-point stretch + flip on midpoint crossing */
364
+ /* ------------------------------------------------------------------ */
365
+ const touchOverlayRef = ref<HTMLDivElement | null>(null)
366
+ let dragStart: { x: number; y: number } | null = null
367
+ let springRaf = 0
368
+
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
+ }
376
+
377
+ function onPointerDown(e: PointerEvent) {
378
+ // Cancel any in-progress spring animation
379
+ if (springRaf) {
380
+ cancelAnimationFrame(springRaf)
381
+ springRaf = 0
382
+ }
383
+
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
389
+ target.setPointerCapture(e.pointerId)
390
+
391
+ isDragging.value = true
392
+ dragStart = { x: e.clientX, y: e.clientY }
393
+
394
+ globalThis.addEventListener('pointermove', onPointerMove)
395
+ globalThis.addEventListener('pointerup', onPointerUp)
396
+ globalThis.addEventListener('pointercancel', onPointerUp)
397
+ }
398
+
399
+ function onPointerMove(e: PointerEvent) {
400
+ if (!dragStart) return
401
+
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)
413
+ }
414
+ }
415
+
416
+ function onPointerUp(e: PointerEvent) {
417
+ if (touchOverlayRef.value) {
418
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* already released */ }
419
+ }
420
+ isDragging.value = false
421
+ dragStart = null
422
+ removeListeners()
423
+ animateSnapBack()
424
+ }
425
+
426
+ function removeListeners() {
427
+ globalThis.removeEventListener('pointermove', onPointerMove)
428
+ globalThis.removeEventListener('pointerup', onPointerUp)
429
+ globalThis.removeEventListener('pointercancel', onPointerUp)
430
+ }
431
+
432
+ /**
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.
436
+ */
437
+ function triggerFlip(newSide: 'left' | 'right', e: PointerEvent) {
438
+ // Reset stretch to default shape
439
+ stretchX.value = 0
440
+ stretchY.value = 0
441
+
442
+ // End drag
443
+ isDragging.value = false
444
+ dragStart = null
445
+ removeListeners()
446
+
447
+ // Release pointer capture
448
+ if (touchOverlayRef.value) {
449
+ try { touchOverlayRef.value.releasePointerCapture(e.pointerId) } catch { /* noop */ }
450
+ }
451
+
452
+ // Switch to the new side
453
+ side.value = newSide
454
+ saveSide(newSide)
455
+ }
456
+
457
+ /** Spring-physics animation to snap the control point back to default */
458
+ function animateSnapBack() {
459
+ const stiffness = 0.12
460
+ const damping = 0.72
461
+ let vx = 0
462
+ let vy = 0
463
+
464
+ function step() {
465
+ vx = (vx - stiffness * stretchX.value) * damping
466
+ vy = (vy - stiffness * stretchY.value) * damping
467
+ stretchX.value += vx
468
+ stretchY.value += vy
469
+
470
+ if (
471
+ Math.abs(stretchX.value) < 0.3 &&
472
+ Math.abs(stretchY.value) < 0.3 &&
473
+ Math.abs(vx) < 0.3 &&
474
+ Math.abs(vy) < 0.3
475
+ ) {
476
+ stretchX.value = 0
477
+ stretchY.value = 0
478
+ springRaf = 0
479
+ return
480
+ }
481
+
482
+ springRaf = requestAnimationFrame(step)
483
+ }
484
+
485
+ springRaf = requestAnimationFrame(step)
486
+ }
487
+
488
+ /* ------------------------------------------------------------------ */
489
+ /* Cleanup */
490
+ /* ------------------------------------------------------------------ */
491
+ onBeforeUnmount(() => {
492
+ if (springRaf) cancelAnimationFrame(springRaf)
493
+ removeListeners()
494
+ })
495
+ </script>
496
+
497
+ <style scoped>
498
+ /* ------------------------------------------------------------------ */
499
+ /* Container — fixed in corner, non-blocking */
500
+ /* ------------------------------------------------------------------ */
501
+ .dcs-ribbon-container {
502
+ position: fixed;
503
+ top: 0;
504
+ z-index: 2147483647;
505
+ pointer-events: none;
506
+ user-select: none;
507
+ -webkit-user-select: none;
508
+ }
509
+
510
+ .dcs-ribbon-container--left {
511
+ left: 0;
512
+ right: auto;
513
+ }
514
+
515
+ .dcs-ribbon-container--right {
516
+ right: 0;
517
+ left: auto;
518
+ }
519
+
520
+ /* ------------------------------------------------------------------ */
521
+ /* SVG canvas */
522
+ /* ------------------------------------------------------------------ */
523
+ .dcs-ribbon-svg {
524
+ display: block;
525
+ width: 260px;
526
+ height: 260px;
527
+ overflow: visible;
528
+ pointer-events: none;
529
+ }
530
+
531
+ /* ------------------------------------------------------------------ */
532
+ /* Band (visual only — touch handled by overlay div) */
533
+ /* ------------------------------------------------------------------ */
534
+ .dcs-ribbon-band {
535
+ pointer-events: none;
536
+ }
537
+
538
+ /* ------------------------------------------------------------------ */
539
+ /* Stitched borders */
540
+ /* ------------------------------------------------------------------ */
541
+ .dcs-ribbon-stitch {
542
+ fill: none;
543
+ stroke: rgba(255, 255, 255, 0.15);
544
+ stroke-width: 1;
545
+ stroke-dasharray: 4 3;
546
+ pointer-events: none;
547
+ }
548
+
549
+ /* ------------------------------------------------------------------ */
550
+ /* Text */
551
+ /* ------------------------------------------------------------------ */
552
+ .dcs-ribbon-text {
553
+ fill: #e0e7ff;
554
+ font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
555
+ font-weight: 600;
556
+ font-size: 12px;
557
+ letter-spacing: 0.06em;
558
+ text-transform: uppercase;
559
+ pointer-events: none;
560
+ }
561
+
562
+ /* Full label for wide screens */
563
+ .dcs-ribbon-text--full {
564
+ display: block;
565
+ }
566
+ .dcs-ribbon-text--compact {
567
+ display: none;
568
+ }
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
+
595
+ /* Compact label for narrow screens */
596
+ @media (max-width: 640px) {
597
+ .dcs-ribbon-svg {
598
+ width: 200px;
599
+ height: 200px;
600
+ }
601
+ .dcs-ribbon-touch {
602
+ width: 200px;
603
+ height: 200px;
604
+ }
605
+ .dcs-ribbon-text--full {
606
+ display: none;
607
+ }
608
+ .dcs-ribbon-text--compact {
609
+ display: block;
610
+ font-size: 11px;
611
+ }
612
+ }
613
613
  </style>