@fy-/fws-vue-core 3.0.4 → 3.0.6

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 (121) hide show
  1. package/package.json +6 -8
  2. package/src/components/fws/CmsArticleBoxed.vue +247 -0
  3. package/src/components/fws/CmsArticleSingle.vue +201 -0
  4. package/src/components/fws/DataTable.vue +659 -0
  5. package/src/components/fws/FilterData.vue +423 -0
  6. package/src/components/fws/UserData.vue +220 -0
  7. package/src/components/fws/UserFlow.vue +955 -0
  8. package/src/components/fws/UserOAuth2.vue +521 -0
  9. package/src/components/fws/UserProfile.vue +615 -0
  10. package/src/components/fws/UserProfileStrict.vue +233 -0
  11. package/src/components/ssr/ClientOnly.ts +10 -0
  12. package/src/components/ui/DefaultBreadcrumb.vue +99 -0
  13. package/src/components/ui/DefaultConfirm.vue +178 -0
  14. package/src/components/ui/DefaultConfirmWithInput.vue +217 -0
  15. package/src/components/ui/DefaultDropdown.vue +104 -0
  16. package/src/components/ui/DefaultDropdownLink.vue +94 -0
  17. package/src/components/ui/DefaultGallery.vue +1056 -0
  18. package/src/components/ui/DefaultInput.vue +768 -0
  19. package/src/components/ui/DefaultLoader.vue +125 -0
  20. package/src/components/ui/DefaultModal.vue +350 -0
  21. package/src/components/ui/DefaultNotif.vue +332 -0
  22. package/src/components/ui/DefaultPaging.vue +395 -0
  23. package/src/components/ui/DefaultSidebar.vue +267 -0
  24. package/src/components/ui/DefaultTagInput.vue +415 -0
  25. package/src/components/ui/transitions/CollapseTransition.vue +19 -0
  26. package/src/components/ui/transitions/ExpandTransition.vue +19 -0
  27. package/src/components/ui/transitions/FadeTransition.vue +17 -0
  28. package/src/components/ui/transitions/ScaleTransition.vue +21 -0
  29. package/src/components/ui/transitions/SlideTransition.vue +32 -0
  30. package/src/composables/event-bus.ts +15 -0
  31. package/src/composables/rest.ts +165 -0
  32. package/src/composables/seo.ts +142 -0
  33. package/src/composables/ssr.ts +103 -0
  34. package/src/composables/templating.ts +133 -0
  35. package/src/composables/translations.ts +45 -0
  36. package/src/env.d.ts +10 -0
  37. package/{dist/src/index.d.ts → src/index.ts} +71 -45
  38. package/src/plugin.ts +42 -0
  39. package/src/safelist.html +11 -0
  40. package/src/stores/serverRouter.ts +62 -0
  41. package/src/stores/user.ts +118 -0
  42. package/src/types.ts +58 -0
  43. package/dist/index.css +0 -2
  44. package/dist/index.js +0 -5767
  45. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts +0 -32
  46. package/dist/src/components/fws/CmsArticleBoxed.vue.d.ts.map +0 -1
  47. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts +0 -29
  48. package/dist/src/components/fws/CmsArticleSingle.vue.d.ts.map +0 -1
  49. package/dist/src/components/fws/DataTable.vue.d.ts +0 -52
  50. package/dist/src/components/fws/DataTable.vue.d.ts.map +0 -1
  51. package/dist/src/components/fws/FilterData.vue.d.ts +0 -15
  52. package/dist/src/components/fws/FilterData.vue.d.ts.map +0 -1
  53. package/dist/src/components/fws/UserData.vue.d.ts +0 -8
  54. package/dist/src/components/fws/UserData.vue.d.ts.map +0 -1
  55. package/dist/src/components/fws/UserFlow.vue.d.ts +0 -116
  56. package/dist/src/components/fws/UserFlow.vue.d.ts.map +0 -1
  57. package/dist/src/components/fws/UserOAuth2.vue.d.ts +0 -17
  58. package/dist/src/components/fws/UserOAuth2.vue.d.ts.map +0 -1
  59. package/dist/src/components/fws/UserProfile.vue.d.ts +0 -40
  60. package/dist/src/components/fws/UserProfile.vue.d.ts.map +0 -1
  61. package/dist/src/components/fws/UserProfileStrict.vue.d.ts +0 -12
  62. package/dist/src/components/fws/UserProfileStrict.vue.d.ts.map +0 -1
  63. package/dist/src/components/ssr/ClientOnly.d.ts +0 -4
  64. package/dist/src/components/ssr/ClientOnly.d.ts.map +0 -1
  65. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts +0 -11
  66. package/dist/src/components/ui/DefaultBreadcrumb.vue.d.ts.map +0 -1
  67. package/dist/src/components/ui/DefaultConfirm.vue.d.ts +0 -81
  68. package/dist/src/components/ui/DefaultConfirm.vue.d.ts.map +0 -1
  69. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts +0 -81
  70. package/dist/src/components/ui/DefaultConfirmWithInput.vue.d.ts.map +0 -1
  71. package/dist/src/components/ui/DefaultDropdown.vue.d.ts +0 -35
  72. package/dist/src/components/ui/DefaultDropdown.vue.d.ts.map +0 -1
  73. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts +0 -23
  74. package/dist/src/components/ui/DefaultDropdownLink.vue.d.ts.map +0 -1
  75. package/dist/src/components/ui/DefaultGallery.vue.d.ts +0 -114
  76. package/dist/src/components/ui/DefaultGallery.vue.d.ts.map +0 -1
  77. package/dist/src/components/ui/DefaultInput.vue.d.ts +0 -61
  78. package/dist/src/components/ui/DefaultInput.vue.d.ts.map +0 -1
  79. package/dist/src/components/ui/DefaultLoader.vue.d.ts +0 -12
  80. package/dist/src/components/ui/DefaultLoader.vue.d.ts.map +0 -1
  81. package/dist/src/components/ui/DefaultModal.vue.d.ts +0 -36
  82. package/dist/src/components/ui/DefaultModal.vue.d.ts.map +0 -1
  83. package/dist/src/components/ui/DefaultNotif.vue.d.ts +0 -3
  84. package/dist/src/components/ui/DefaultNotif.vue.d.ts.map +0 -1
  85. package/dist/src/components/ui/DefaultPaging.vue.d.ts +0 -13
  86. package/dist/src/components/ui/DefaultPaging.vue.d.ts.map +0 -1
  87. package/dist/src/components/ui/DefaultSidebar.vue.d.ts +0 -29
  88. package/dist/src/components/ui/DefaultSidebar.vue.d.ts.map +0 -1
  89. package/dist/src/components/ui/DefaultTagInput.vue.d.ts +0 -34
  90. package/dist/src/components/ui/DefaultTagInput.vue.d.ts.map +0 -1
  91. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts +0 -18
  92. package/dist/src/components/ui/transitions/CollapseTransition.vue.d.ts.map +0 -1
  93. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts +0 -18
  94. package/dist/src/components/ui/transitions/ExpandTransition.vue.d.ts.map +0 -1
  95. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts +0 -18
  96. package/dist/src/components/ui/transitions/FadeTransition.vue.d.ts.map +0 -1
  97. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts +0 -18
  98. package/dist/src/components/ui/transitions/ScaleTransition.vue.d.ts.map +0 -1
  99. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts +0 -21
  100. package/dist/src/components/ui/transitions/SlideTransition.vue.d.ts.map +0 -1
  101. package/dist/src/composables/event-bus.d.ts +0 -8
  102. package/dist/src/composables/event-bus.d.ts.map +0 -1
  103. package/dist/src/composables/rest.d.ts +0 -24
  104. package/dist/src/composables/rest.d.ts.map +0 -1
  105. package/dist/src/composables/seo.d.ts +0 -26
  106. package/dist/src/composables/seo.d.ts.map +0 -1
  107. package/dist/src/composables/ssr.d.ts +0 -24
  108. package/dist/src/composables/ssr.d.ts.map +0 -1
  109. package/dist/src/composables/templating.d.ts +0 -7
  110. package/dist/src/composables/templating.d.ts.map +0 -1
  111. package/dist/src/composables/translations.d.ts +0 -8
  112. package/dist/src/composables/translations.d.ts.map +0 -1
  113. package/dist/src/index.d.ts.map +0 -1
  114. package/dist/src/plugin.d.ts +0 -3
  115. package/dist/src/plugin.d.ts.map +0 -1
  116. package/dist/src/stores/serverRouter.d.ts +0 -34
  117. package/dist/src/stores/serverRouter.d.ts.map +0 -1
  118. package/dist/src/stores/user.d.ts +0 -139
  119. package/dist/src/stores/user.d.ts.map +0 -1
  120. package/dist/src/types.d.ts +0 -48
  121. package/dist/src/types.d.ts.map +0 -1
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ import { useDebounceFn } from '@vueuse/core'
3
+ import { computed, onMounted, onUnmounted, ref } from 'vue'
4
+ import { useEventBus } from '../../composables/event-bus'
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ image?: string
9
+ force?: boolean
10
+ id?: string
11
+ }>(),
12
+ {
13
+ image: '',
14
+ force: false,
15
+ id: '',
16
+ },
17
+ )
18
+
19
+ const eventBus = useEventBus()
20
+ const loading = ref(false)
21
+
22
+ const eventName = computed(() => props.id ? `${props.id}-loading` : 'loading')
23
+
24
+ const setLoading = useDebounceFn((value: boolean) => {
25
+ loading.value = value
26
+ }, 50)
27
+
28
+ onMounted(() => { eventBus.on(eventName.value, setLoading) })
29
+ onUnmounted(() => { eventBus.off(eventName.value, setLoading) })
30
+ </script>
31
+
32
+ <template>
33
+ <Transition
34
+ enter-active-class="fv-loader-enter-active"
35
+ enter-from-class="fv-loader-enter-from"
36
+ leave-active-class="fv-loader-leave-active"
37
+ leave-to-class="fv-loader-leave-to"
38
+ >
39
+ <div v-if="loading || force" class="fv-loader">
40
+ <div class="fv-loader__panel">
41
+ <img
42
+ v-if="image"
43
+ :src="image"
44
+ :alt="$t('global_loading')"
45
+ class="fv-loader__image"
46
+ >
47
+ <div v-else class="fv-loader__spinner" />
48
+ </div>
49
+ </div>
50
+ </Transition>
51
+ </template>
52
+
53
+ <style scoped>
54
+ .fv-loader {
55
+ position: absolute;
56
+ inset: 0;
57
+ z-index: 50;
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+
62
+ /* Frosted glass overlay */
63
+ background: rgba(255, 255, 255, 0.6);
64
+ backdrop-filter: blur(8px) saturate(1.1);
65
+ -webkit-backdrop-filter: blur(8px) saturate(1.1);
66
+ }
67
+ :is(.dark) .fv-loader {
68
+ background: rgba(10, 10, 10, 0.6);
69
+ }
70
+
71
+ .fv-loader__panel {
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ width: 4rem;
76
+ height: 4rem;
77
+ border-radius: 1rem;
78
+ background: rgba(255, 255, 255, 0.9);
79
+ box-shadow: rgba(0,0,0,0.08) 0 0 0 1px, rgba(0,0,0,0.06) 0 8px 24px;
80
+ }
81
+ :is(.dark) .fv-loader__panel {
82
+ background: rgba(30, 30, 30, 0.9);
83
+ box-shadow: none;
84
+ border: 1px solid rgba(255, 255, 255, 0.1);
85
+ }
86
+
87
+ .fv-loader__image {
88
+ width: 2.5rem;
89
+ height: 2.5rem;
90
+ animation: fv-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
91
+ }
92
+
93
+ /* Spinner fallback when no image */
94
+ .fv-loader__spinner {
95
+ width: 1.5rem;
96
+ height: 1.5rem;
97
+ border: 2px solid rgba(0, 0, 0, 0.1);
98
+ border-top-color: var(--color-fv-primary-600, #7c3aed);
99
+ border-radius: 50%;
100
+ animation: fv-spin 0.6s linear infinite;
101
+ }
102
+ :is(.dark) .fv-loader__spinner {
103
+ border-color: rgba(255, 255, 255, 0.1);
104
+ border-top-color: var(--color-fv-primary-400, #a78bfa);
105
+ }
106
+
107
+ @keyframes fv-pulse {
108
+ 0%, 100% { opacity: 1; transform: scale(1); }
109
+ 50% { opacity: 0.6; transform: scale(0.95); }
110
+ }
111
+
112
+ @keyframes fv-spin {
113
+ to { transform: rotate(360deg); }
114
+ }
115
+
116
+ /* Entry/exit */
117
+ .fv-loader-enter-active { transition: opacity 200ms ease-out; }
118
+ .fv-loader-leave-active { transition: opacity 150ms ease-in; }
119
+ .fv-loader-enter-from, .fv-loader-leave-to { opacity: 0; }
120
+
121
+ @media (prefers-reduced-motion: reduce) {
122
+ .fv-loader__image { animation: none; }
123
+ .fv-loader__spinner { animation: none; border-top-color: var(--color-fv-primary-600, #7c3aed); }
124
+ }
125
+ </style>
@@ -0,0 +1,350 @@
1
+ <script setup lang="ts">
2
+ import { XMarkIcon } from '@heroicons/vue/24/solid'
3
+ import { useDebounceFn, useEventListener } from '@vueuse/core'
4
+ import { computed, h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
5
+ import { useEventBus } from '../../composables/event-bus'
6
+ import { ClientOnly } from '../ssr/ClientOnly'
7
+
8
+ // Global modal registry for z-index stacking
9
+ if (typeof window !== 'undefined') {
10
+ // @ts-expect-error: global registry
11
+ window.__FWS_MODAL_REGISTRY = window.__FWS_MODAL_REGISTRY || {
12
+ modals: new Map<string, number>(),
13
+ baseZ: 40,
14
+ maxZ: 59,
15
+ getNextZIndex() {
16
+ if (this.modals.size === 0) return this.baseZ
17
+ const highest = Math.max(...Array.from(this.modals.values()) as number[])
18
+ const next = highest + 1
19
+ if (next >= this.maxZ) {
20
+ this.resetAll()
21
+ return this.baseZ
22
+ }
23
+ return next
24
+ },
25
+ resetAll() {
26
+ const sorted = Array.from(this.modals.entries()).sort((a: any, b: any) => a[1] - b[1])
27
+ let z = this.baseZ
28
+ sorted.forEach(([id]: any) => { this.modals.set(id, z++) })
29
+ },
30
+ }
31
+ }
32
+
33
+ // @ts-expect-error: global registry
34
+ const registry = typeof window !== 'undefined' ? window.__FWS_MODAL_REGISTRY : { modals: new Map() }
35
+
36
+ const props = withDefaults(
37
+ defineProps<{
38
+ id: string
39
+ title?: string
40
+ onOpen?: Function
41
+ onClose?: Function
42
+ closeIcon?: object
43
+ mSize?: string
44
+ ofy?: string
45
+ }>(),
46
+ {
47
+ closeIcon: () => h(XMarkIcon),
48
+ mSize: 'w-full',
49
+ ofy: 'overflow-y-auto',
50
+ },
51
+ )
52
+
53
+ const eventBus = useEventBus()
54
+ const isOpen = ref(false)
55
+ const modalRef = shallowRef<HTMLElement | null>(null)
56
+ const zIndex = ref(40)
57
+ const modalUniqueId = shallowRef('')
58
+ let previouslyFocusedElement: HTMLElement | null = null
59
+ let focusableElements: HTMLElement[] = []
60
+
61
+ const focusableSelector = 'a[href], button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), details, [tabindex]:not([tabindex="-1"])'
62
+
63
+ const modalPanelClasses = computed(() =>
64
+ `fv-modal__panel ${props.mSize}`,
65
+ )
66
+
67
+ function getFocusableElements(el: HTMLElement): HTMLElement[] {
68
+ return Array.from(el.querySelectorAll(focusableSelector))
69
+ .filter(e => !e.hasAttribute('disabled') && !e.getAttribute('aria-hidden')) as HTMLElement[]
70
+ }
71
+
72
+ function isTopMost(id: string): boolean {
73
+ if (registry.modals.size === 0) return false
74
+ const entries = Array.from(registry.modals.entries())
75
+ const top = entries.reduce((prev: any, curr: any) => curr[1] > prev[1] ? curr : prev)
76
+ return top[0] === id
77
+ }
78
+
79
+ const handleKeyDown = useDebounceFn((event: KeyboardEvent) => {
80
+ if (!isOpen.value || !isTopMost(modalUniqueId.value)) return
81
+
82
+ if (event.key === 'Escape') {
83
+ event.preventDefault()
84
+ setModal(false)
85
+ return
86
+ }
87
+
88
+ if (event.key === 'Tab' && focusableElements.length > 0) {
89
+ const first = focusableElements[0]
90
+ const last = focusableElements[focusableElements.length - 1]
91
+ if (event.shiftKey && document.activeElement === first) {
92
+ event.preventDefault()
93
+ last.focus()
94
+ } else if (!event.shiftKey && document.activeElement === last) {
95
+ event.preventDefault()
96
+ first.focus()
97
+ }
98
+ }
99
+ }, 10)
100
+
101
+ const setModal = useDebounceFn((value: boolean) => {
102
+ if (value) {
103
+ props.onOpen?.()
104
+ previouslyFocusedElement = document.activeElement as HTMLElement
105
+
106
+ const newZ = registry.getNextZIndex()
107
+ const uid = `${props.id}-${Date.now()}`
108
+ modalUniqueId.value = uid
109
+ registry.modals.set(uid, newZ)
110
+ zIndex.value = newZ
111
+
112
+ useEventListener(document, 'keydown', handleKeyDown)
113
+ } else {
114
+ props.onClose?.()
115
+ if (modalUniqueId.value) registry.modals.delete(modalUniqueId.value)
116
+ previouslyFocusedElement?.focus()
117
+ }
118
+ isOpen.value = value
119
+ }, 50)
120
+
121
+ watch(isOpen, async (open) => {
122
+ if (!open) return
123
+ await nextTick()
124
+ const el = modalRef.value
125
+ if (!el) return
126
+ focusableElements = getFocusableElements(el)
127
+ requestAnimationFrame(() => {
128
+ const close = el.querySelector('button[aria-label="Close modal"]') as HTMLElement
129
+ if (close) close.focus()
130
+ else if (focusableElements.length) focusableElements[0].focus()
131
+ else el.focus()
132
+ })
133
+ })
134
+
135
+ const handleBackdropClick = useDebounceFn((event: MouseEvent) => {
136
+ if (event.target === event.currentTarget) setModal(false)
137
+ }, 200)
138
+
139
+ const modalEventName = `${props.id}Modal`
140
+ onMounted(() => { eventBus.on(modalEventName, setModal) })
141
+ onUnmounted(() => {
142
+ eventBus.off(modalEventName, setModal)
143
+ if (isOpen.value && modalUniqueId.value) registry.modals.delete(modalUniqueId.value)
144
+ })
145
+ </script>
146
+
147
+ <template>
148
+ <ClientOnly>
149
+ <Transition
150
+ enter-active-class="fv-modal-enter-active"
151
+ enter-from-class="fv-modal-enter-from"
152
+ leave-active-class="fv-modal-leave-active"
153
+ leave-to-class="fv-modal-leave-to"
154
+ >
155
+ <div
156
+ v-if="isOpen"
157
+ class="fv-modal"
158
+ :style="{ zIndex }"
159
+ role="dialog"
160
+ :aria-labelledby="title ? `${id}-title` : undefined"
161
+ aria-modal="true"
162
+ :data-modal-id="id"
163
+ >
164
+ <!-- Frosted glass backdrop -->
165
+ <div
166
+ class="fv-modal__backdrop"
167
+ :style="{ zIndex }"
168
+ @click="handleBackdropClick"
169
+ >
170
+ <!-- Panel -->
171
+ <div
172
+ ref="modalRef"
173
+ :class="modalPanelClasses"
174
+ :style="{ zIndex }"
175
+ tabindex="-1"
176
+ @click.stop
177
+ >
178
+ <!-- Header -->
179
+ <div v-if="title" class="fv-modal__header">
180
+ <slot name="before" />
181
+ <h2
182
+ :id="`${id}-title`"
183
+ class="fv-modal__title"
184
+ v-html="title"
185
+ />
186
+ <button
187
+ class="fv-modal__close"
188
+ aria-label="Close modal"
189
+ @click="setModal(false)"
190
+ >
191
+ <component :is="closeIcon" class="w-5 h-5" />
192
+ </button>
193
+ </div>
194
+
195
+ <!-- Content -->
196
+ <div :class="`fv-modal__content ${ofy}`">
197
+ <slot />
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </Transition>
203
+ </ClientOnly>
204
+ </template>
205
+
206
+ <style scoped>
207
+ /* ═══ Modal — Glass backdrop, Vercel shadow-as-border panel ═══ */
208
+ .fv-modal {
209
+ position: fixed;
210
+ inset: 0;
211
+ }
212
+
213
+ .fv-modal__backdrop {
214
+ display: flex;
215
+ position: fixed;
216
+ inset: 0;
217
+ flex-direction: column;
218
+ align-items: center;
219
+ padding: 1rem;
220
+ overflow-y: auto;
221
+
222
+ /* Linear: near-opaque backdrop for extreme content focus */
223
+ background: rgba(0, 0, 0, 0.6);
224
+ backdrop-filter: blur(8px) saturate(1.1);
225
+ -webkit-backdrop-filter: blur(8px) saturate(1.1);
226
+ }
227
+ @media (min-width: 768px) {
228
+ .fv-modal__backdrop { padding: 2rem 1rem; }
229
+ }
230
+
231
+ :is(.dark) .fv-modal__backdrop {
232
+ background: rgba(0, 0, 0, 0.85);
233
+ }
234
+
235
+ .fv-modal__panel {
236
+ position: relative;
237
+ max-width: min(calc(100vw - 2rem), 72rem);
238
+ max-height: 85vh;
239
+ margin: auto;
240
+ display: flex;
241
+ flex-direction: column;
242
+ border-radius: 0.75rem;
243
+
244
+ /* Stripe: blue-tinted parallax depth shadows + Vercel shadow-as-border */
245
+ background: #ffffff;
246
+ box-shadow:
247
+ rgba(0, 0, 0, 0.08) 0 0 0 1px,
248
+ rgba(50, 50, 93, 0.25) 0px 30px 45px -30px,
249
+ rgba(0, 0, 0, 0.1) 0px 18px 36px -18px;
250
+ }
251
+
252
+ :is(.dark) .fv-modal__panel {
253
+ background: #1a1a1e;
254
+ box-shadow: none;
255
+ border: 1px solid rgba(255, 255, 255, 0.08);
256
+ }
257
+
258
+ .fv-modal__header {
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: space-between;
262
+ padding: 0.75rem 1rem;
263
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
264
+ flex-shrink: 0;
265
+ }
266
+ :is(.dark) .fv-modal__header {
267
+ border-bottom-color: rgba(255, 255, 255, 0.06);
268
+ }
269
+
270
+ .fv-modal__title {
271
+ font-size: 1rem;
272
+ font-weight: 600;
273
+ color: #171717;
274
+ flex: 1;
275
+ min-width: 0;
276
+ }
277
+ @media (min-width: 768px) {
278
+ .fv-modal__title { font-size: 1.125rem; }
279
+ }
280
+ :is(.dark) .fv-modal__title { color: #f5f5f5; }
281
+
282
+ .fv-modal__close {
283
+ flex-shrink: 0;
284
+ display: inline-flex;
285
+ align-items: center;
286
+ justify-content: center;
287
+ width: 2rem;
288
+ height: 2rem;
289
+ border-radius: 0.5rem;
290
+ color: #a3a3a3;
291
+ background: transparent;
292
+ border: none;
293
+ cursor: pointer;
294
+ transition: color 150ms, background-color 150ms;
295
+ }
296
+ .fv-modal__close:hover {
297
+ color: #525252;
298
+ background: rgba(0, 0, 0, 0.05);
299
+ }
300
+ :is(.dark) .fv-modal__close:hover {
301
+ color: #e5e5e5;
302
+ background: rgba(255, 255, 255, 0.08);
303
+ }
304
+ .fv-modal__close:focus-visible {
305
+ outline: 2px solid var(--color-fv-primary-400, #a78bfa);
306
+ outline-offset: -2px;
307
+ }
308
+
309
+ .fv-modal__content {
310
+ padding: 1rem;
311
+ flex-grow: 1;
312
+ }
313
+ @media (min-width: 768px) {
314
+ .fv-modal__content { padding: 1.25rem; }
315
+ }
316
+
317
+ /* ═══ Entrance/exit — scale + fade (Apple/Linear pattern) ═══ */
318
+ .fv-modal-enter-active {
319
+ transition: opacity 200ms cubic-bezier(0.16, 1, 0.3, 1);
320
+ }
321
+ .fv-modal-enter-active .fv-modal__panel {
322
+ animation: fv-modal-scale-in 250ms cubic-bezier(0.16, 1, 0.3, 1) both;
323
+ }
324
+ .fv-modal-leave-active {
325
+ transition: opacity 150ms ease-in;
326
+ }
327
+ .fv-modal-leave-active .fv-modal__panel {
328
+ animation: fv-modal-scale-out 150ms ease-in both;
329
+ }
330
+ .fv-modal-enter-from,
331
+ .fv-modal-leave-to {
332
+ opacity: 0;
333
+ }
334
+
335
+ @keyframes fv-modal-scale-in {
336
+ from { transform: scale(0.95) translateY(0.5rem); opacity: 0; }
337
+ to { transform: scale(1) translateY(0); opacity: 1; }
338
+ }
339
+ @keyframes fv-modal-scale-out {
340
+ from { transform: scale(1); opacity: 1; }
341
+ to { transform: scale(0.97); opacity: 0; }
342
+ }
343
+
344
+ @media (prefers-reduced-motion: reduce) {
345
+ .fv-modal-enter-active,
346
+ .fv-modal-leave-active { transition: opacity 100ms; }
347
+ .fv-modal-enter-active .fv-modal__panel,
348
+ .fv-modal-leave-active .fv-modal__panel { animation: none; }
349
+ }
350
+ </style>