@softdin/shared 0.1.3

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 (40) hide show
  1. package/README.md +48 -0
  2. package/package.json +50 -0
  3. package/src/a11y/announcer.ts +43 -0
  4. package/src/a11y/apply.ts +83 -0
  5. package/src/a11y/index.ts +31 -0
  6. package/src/a11y/state.ts +40 -0
  7. package/src/a11y/storage.ts +106 -0
  8. package/src/a11y/types.ts +187 -0
  9. package/src/api.ts +89 -0
  10. package/src/assets/softdin-logo.png +0 -0
  11. package/src/auth.ts +134 -0
  12. package/src/components/a11y/A11yLiveRegion.vue +11 -0
  13. package/src/components/a11y/AccessibilityMenuControls.vue +426 -0
  14. package/src/components/a11y/AccessibilityPreferencesPanel.vue +331 -0
  15. package/src/components/a11y/ColorSchemeQuickToggle.vue +105 -0
  16. package/src/components/a11y/SkipLink.vue +41 -0
  17. package/src/components/auth/AuthBrandHeader.vue +129 -0
  18. package/src/components/auth/AuthShell.vue +52 -0
  19. package/src/components/auth/DataProcessingConsentCheckbox.vue +303 -0
  20. package/src/components/auth/DataProcessingConsentForm.vue +111 -0
  21. package/src/components/forms/ErrorAlert.vue +170 -0
  22. package/src/components/forms/FormCancelButton.vue +87 -0
  23. package/src/components/forms/FormCloseButton.vue +114 -0
  24. package/src/components/forms/FormHelpButton.vue +109 -0
  25. package/src/components/forms/FormHelpModal.vue +193 -0
  26. package/src/components/forms/FormMaximizeButton.vue +143 -0
  27. package/src/components/forms/FormRefreshButton.vue +152 -0
  28. package/src/components/forms/FormSaveButton.vue +113 -0
  29. package/src/components/forms/PasswordInput.vue +154 -0
  30. package/src/components/navigation/CatalogListPagination.vue +113 -0
  31. package/src/config/apiBase.ts +37 -0
  32. package/src/lib/catalog-pagination.ts +27 -0
  33. package/src/lib/pagination.ts +86 -0
  34. package/src/lib/softdin-libreria.ts +50 -0
  35. package/src/style.css +278 -0
  36. package/src/types/auth.ts +146 -0
  37. package/src/types/softdin-libreria.d.ts +41 -0
  38. package/src/utils/apiError.ts +55 -0
  39. package/src/utils/connectivity.ts +33 -0
  40. package/src/utils/tenantLogo.ts +6 -0
@@ -0,0 +1,331 @@
1
+ <script setup lang="ts">
2
+ import { watch } from 'vue'
3
+ import {
4
+ accessibilityPreferencesState,
5
+ applyAccessibilityPreferences,
6
+ resetAccessibilityPreferences,
7
+ saveAccessibilityPreferences,
8
+ type A11yBrandAccent,
9
+ type A11yColorScheme,
10
+ type A11yContrast,
11
+ type A11yFontFamily,
12
+ type A11yLineSpacing,
13
+ type A11yMotion,
14
+ type A11yTextScale,
15
+ } from '../../a11y'
16
+
17
+ const prefs = accessibilityPreferencesState
18
+
19
+ const textScaleOptions: Array<{ value: A11yTextScale; label: string }> = [
20
+ { value: 'xs', label: 'Muy pequeño' },
21
+ { value: 'sm', label: 'Pequeño' },
22
+ { value: 'md', label: 'Medio' },
23
+ { value: 'lg', label: 'Grande' },
24
+ { value: 'xl', label: 'Extra' },
25
+ ]
26
+
27
+ const fontFamilyOptions: Array<{ value: A11yFontFamily; label: string }> = [
28
+ { value: 'system', label: 'Sistema' },
29
+ { value: 'serif', label: 'Serif' },
30
+ { value: 'dyslexia', label: 'Lectura fácil' },
31
+ ]
32
+
33
+ const lineSpacingOptions: Array<{ value: A11yLineSpacing; label: string }> = [
34
+ { value: 'normal', label: 'Normal' },
35
+ { value: 'relaxed', label: 'Amplio' },
36
+ ]
37
+
38
+ const colorSchemeOptions: Array<{ value: A11yColorScheme; label: string }> = [
39
+ { value: 'light', label: 'Claro' },
40
+ { value: 'dark', label: 'Oscuro' },
41
+ { value: 'system', label: 'Sistema' },
42
+ ]
43
+
44
+ const brandAccentOptions: Array<{ value: A11yBrandAccent; label: string }> = [
45
+ { value: 'green', label: 'Verde' },
46
+ { value: 'blue', label: 'Azul' },
47
+ { value: 'red', label: 'Rojo' },
48
+ { value: 'purple', label: 'Morado' },
49
+ { value: 'orange', label: 'Naranja' },
50
+ { value: 'teal', label: 'Turquesa' },
51
+ ]
52
+
53
+ const contrastOptions: Array<{ value: A11yContrast; label: string }> = [
54
+ { value: 'default', label: 'Normal' },
55
+ { value: 'high', label: 'Alto' },
56
+ ]
57
+
58
+ const motionOptions: Array<{ value: A11yMotion; label: string }> = [
59
+ { value: 'system', label: 'Según el sistema' },
60
+ { value: 'reduce', label: 'Reducir animaciones' },
61
+ ]
62
+
63
+ watch(
64
+ prefs,
65
+ (next) => {
66
+ applyAccessibilityPreferences({ ...next })
67
+ saveAccessibilityPreferences({ ...next })
68
+ },
69
+ { deep: true },
70
+ )
71
+
72
+ function handleReset(): void {
73
+ resetAccessibilityPreferences()
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <section id="accesibilidad" class="a11y-panel" aria-labelledby="a11y-panel-title" tabindex="-1">
79
+ <div>
80
+ <p id="a11y-panel-title" class="a11y-panel__section-label">Accesibilidad</p>
81
+ <p class="a11y-panel__hint">
82
+ Ajuste tipografía, colores y movimiento. Los cambios se aplican de inmediato y se guardan en
83
+ este navegador.
84
+ </p>
85
+ </div>
86
+
87
+ <p class="a11y-panel__preview" aria-live="polite">
88
+ Vista previa: el tamaño y el color de la interfaz cambian al instante.
89
+ </p>
90
+
91
+ <fieldset class="a11y-panel__fieldset">
92
+ <legend class="a11y-panel__legend">Tamaño del texto</legend>
93
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Tamaño del texto">
94
+ <label
95
+ v-for="option in textScaleOptions"
96
+ :key="option.value"
97
+ class="a11y-panel__option"
98
+ :class="{ 'is-active': prefs.textScale === option.value }"
99
+ >
100
+ <input
101
+ v-model="prefs.textScale"
102
+ type="radio"
103
+ name="a11y-text-scale"
104
+ :value="option.value"
105
+ />
106
+ <span>{{ option.label }}</span>
107
+ </label>
108
+ </div>
109
+ </fieldset>
110
+
111
+ <fieldset class="a11y-panel__fieldset">
112
+ <legend class="a11y-panel__legend">Tipo de fuente</legend>
113
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Tipo de fuente">
114
+ <label
115
+ v-for="option in fontFamilyOptions"
116
+ :key="option.value"
117
+ class="a11y-panel__option"
118
+ :class="{ 'is-active': prefs.fontFamily === option.value }"
119
+ >
120
+ <input
121
+ v-model="prefs.fontFamily"
122
+ type="radio"
123
+ name="a11y-font-family"
124
+ :value="option.value"
125
+ />
126
+ <span>{{ option.label }}</span>
127
+ </label>
128
+ </div>
129
+ </fieldset>
130
+
131
+ <fieldset class="a11y-panel__fieldset">
132
+ <legend class="a11y-panel__legend">Espaciado</legend>
133
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Espaciado del texto">
134
+ <label
135
+ v-for="option in lineSpacingOptions"
136
+ :key="option.value"
137
+ class="a11y-panel__option"
138
+ :class="{ 'is-active': prefs.lineSpacing === option.value }"
139
+ >
140
+ <input
141
+ v-model="prefs.lineSpacing"
142
+ type="radio"
143
+ name="a11y-line-spacing"
144
+ :value="option.value"
145
+ />
146
+ <span>{{ option.label }}</span>
147
+ </label>
148
+ </div>
149
+ </fieldset>
150
+
151
+ <fieldset class="a11y-panel__fieldset">
152
+ <legend class="a11y-panel__legend">Color de la interfaz</legend>
153
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Color de la interfaz">
154
+ <label
155
+ v-for="option in colorSchemeOptions"
156
+ :key="option.value"
157
+ class="a11y-panel__option"
158
+ :class="{ 'is-active': prefs.colorScheme === option.value }"
159
+ >
160
+ <input
161
+ v-model="prefs.colorScheme"
162
+ type="radio"
163
+ name="a11y-color-scheme"
164
+ :value="option.value"
165
+ />
166
+ <span>{{ option.label }}</span>
167
+ </label>
168
+ </div>
169
+ </fieldset>
170
+
171
+ <fieldset class="a11y-panel__fieldset">
172
+ <legend class="a11y-panel__legend">Color de acento (botones y formularios)</legend>
173
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Color de acento">
174
+ <label
175
+ v-for="option in brandAccentOptions"
176
+ :key="option.value"
177
+ class="a11y-panel__option"
178
+ :class="{ 'is-active': prefs.brandAccent === option.value }"
179
+ >
180
+ <input
181
+ v-model="prefs.brandAccent"
182
+ type="radio"
183
+ name="a11y-brand-accent"
184
+ :value="option.value"
185
+ />
186
+ <span>{{ option.label }}</span>
187
+ </label>
188
+ </div>
189
+ </fieldset>
190
+
191
+ <fieldset class="a11y-panel__fieldset">
192
+ <legend class="a11y-panel__legend">Contraste</legend>
193
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Contraste">
194
+ <label
195
+ v-for="option in contrastOptions"
196
+ :key="option.value"
197
+ class="a11y-panel__option"
198
+ :class="{ 'is-active': prefs.contrast === option.value }"
199
+ >
200
+ <input
201
+ v-model="prefs.contrast"
202
+ type="radio"
203
+ name="a11y-contrast"
204
+ :value="option.value"
205
+ />
206
+ <span>{{ option.label }}</span>
207
+ </label>
208
+ </div>
209
+ </fieldset>
210
+
211
+ <fieldset class="a11y-panel__fieldset">
212
+ <legend class="a11y-panel__legend">Movimiento</legend>
213
+ <div class="a11y-panel__options" role="radiogroup" aria-label="Movimiento">
214
+ <label
215
+ v-for="option in motionOptions"
216
+ :key="option.value"
217
+ class="a11y-panel__option"
218
+ :class="{ 'is-active': prefs.motion === option.value }"
219
+ >
220
+ <input v-model="prefs.motion" type="radio" name="a11y-motion" :value="option.value" />
221
+ <span>{{ option.label }}</span>
222
+ </label>
223
+ </div>
224
+ </fieldset>
225
+
226
+ <div class="a11y-panel__actions">
227
+ <button type="button" class="a11y-panel__reset" @click="handleReset">
228
+ Restablecer accesibilidad
229
+ </button>
230
+ </div>
231
+ </section>
232
+ </template>
233
+
234
+ <style scoped>
235
+ .a11y-panel {
236
+ display: flex;
237
+ flex-direction: column;
238
+ gap: 1.25rem;
239
+ }
240
+
241
+ .a11y-panel__section-label {
242
+ margin: 0;
243
+ font-size: 0.875rem;
244
+ font-weight: 600;
245
+ color: var(--color-text, #0f172a);
246
+ }
247
+
248
+ .a11y-panel__hint {
249
+ margin: 0.25rem 0 0;
250
+ font-size: 0.75rem;
251
+ color: var(--color-text-muted, #64748b);
252
+ }
253
+
254
+ .a11y-panel__preview {
255
+ margin: 0;
256
+ border-radius: 0.5rem;
257
+ border: 1px solid var(--color-border, #e2e8f0);
258
+ background: var(--color-surface, #f8fafc);
259
+ padding: 0.75rem 1rem;
260
+ font-size: 0.875rem;
261
+ color: var(--color-text, #0f172a);
262
+ }
263
+
264
+ .a11y-panel__fieldset {
265
+ margin: 0;
266
+ border: 0;
267
+ padding: 0;
268
+ min-inline-size: 0;
269
+ }
270
+
271
+ .a11y-panel__legend {
272
+ padding: 0;
273
+ margin-bottom: 0.5rem;
274
+ font-size: 0.875rem;
275
+ font-weight: 500;
276
+ color: var(--color-text, #334155);
277
+ }
278
+
279
+ .a11y-panel__options {
280
+ display: flex;
281
+ flex-wrap: wrap;
282
+ gap: 0.5rem;
283
+ }
284
+
285
+ .a11y-panel__option {
286
+ display: inline-flex;
287
+ align-items: center;
288
+ gap: 0.375rem;
289
+ border-radius: 0.5rem;
290
+ border: 1px solid var(--color-border, #cbd5e1);
291
+ background: var(--color-panel, #fff);
292
+ padding: 0.5rem 0.75rem;
293
+ font-size: 0.875rem;
294
+ color: var(--color-text, #334155);
295
+ }
296
+
297
+ .a11y-panel__option.is-active {
298
+ border-color: var(--color-brand-600);
299
+ background: var(--color-brand-50);
300
+ color: var(--color-brand-800);
301
+ box-shadow: 0 0 0 1px var(--color-brand-600);
302
+ }
303
+
304
+ .a11y-panel__option input {
305
+ margin: 0;
306
+ }
307
+
308
+ .a11y-panel__actions {
309
+ display: flex;
310
+ flex-wrap: wrap;
311
+ gap: 0.75rem;
312
+ }
313
+
314
+ .a11y-panel__reset {
315
+ border-radius: 0.5rem;
316
+ border: 1px solid var(--color-border, #cbd5e1);
317
+ background: var(--color-panel, #fff);
318
+ padding: 0.5rem 0.875rem;
319
+ font-size: 0.875rem;
320
+ color: var(--color-text, #334155);
321
+ }
322
+
323
+ .a11y-panel__reset:hover {
324
+ background: var(--color-surface, #f8fafc);
325
+ }
326
+
327
+ .a11y-panel__reset:focus-visible {
328
+ outline: 2px solid var(--color-brand-600);
329
+ outline-offset: 2px;
330
+ }
331
+ </style>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ accessibilityPreferencesState,
5
+ patchAccessibilityPreferences,
6
+ type A11yColorScheme,
7
+ } from '../../a11y'
8
+
9
+ const nextScheme = computed<A11yColorScheme>(() => {
10
+ if (accessibilityPreferencesState.colorScheme === 'light') {
11
+ return 'dark'
12
+ }
13
+ if (accessibilityPreferencesState.colorScheme === 'dark') {
14
+ return 'system'
15
+ }
16
+ return 'light'
17
+ })
18
+
19
+ const label = computed(() => {
20
+ if (accessibilityPreferencesState.colorScheme === 'light') {
21
+ return 'Tema claro. Cambiar a oscuro'
22
+ }
23
+ if (accessibilityPreferencesState.colorScheme === 'dark') {
24
+ return 'Tema oscuro. Cambiar a sistema'
25
+ }
26
+ return 'Tema del sistema. Cambiar a claro'
27
+ })
28
+
29
+ const icon = computed(() => {
30
+ if (accessibilityPreferencesState.colorScheme === 'light') {
31
+ return '☀'
32
+ }
33
+ if (accessibilityPreferencesState.colorScheme === 'dark') {
34
+ return '☾'
35
+ }
36
+ return '◐'
37
+ })
38
+
39
+ const shortLabel = computed(() => {
40
+ if (accessibilityPreferencesState.colorScheme === 'light') {
41
+ return 'Claro'
42
+ }
43
+ if (accessibilityPreferencesState.colorScheme === 'dark') {
44
+ return 'Oscuro'
45
+ }
46
+ return 'Auto'
47
+ })
48
+
49
+ function cycleColorScheme(): void {
50
+ patchAccessibilityPreferences({ colorScheme: nextScheme.value })
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <button
56
+ type="button"
57
+ class="color-scheme-toggle"
58
+ :aria-label="label"
59
+ :title="label"
60
+ @click="cycleColorScheme"
61
+ >
62
+ <span class="color-scheme-toggle__icon" aria-hidden="true">{{ icon }}</span>
63
+ <span class="color-scheme-toggle__text">{{ shortLabel }}</span>
64
+ </button>
65
+ </template>
66
+
67
+ <style scoped>
68
+ .color-scheme-toggle {
69
+ display: inline-flex;
70
+ height: 2.25rem;
71
+ align-items: center;
72
+ gap: 0.375rem;
73
+ border-radius: 0.5rem;
74
+ border: 1px solid var(--color-border, #e2e8f0);
75
+ background: var(--color-panel, #fff);
76
+ padding: 0 0.625rem;
77
+ font-size: 0.75rem;
78
+ font-weight: 600;
79
+ color: var(--color-text, #334155);
80
+ }
81
+
82
+ .color-scheme-toggle:hover {
83
+ background: var(--color-surface, #f8fafc);
84
+ }
85
+
86
+ .color-scheme-toggle:focus-visible {
87
+ outline: 2px solid var(--color-brand-600, #16a34a);
88
+ outline-offset: 2px;
89
+ }
90
+
91
+ .color-scheme-toggle__icon {
92
+ font-size: 0.875rem;
93
+ line-height: 1;
94
+ }
95
+
96
+ .color-scheme-toggle__text {
97
+ display: none;
98
+ }
99
+
100
+ @media (min-width: 640px) {
101
+ .color-scheme-toggle__text {
102
+ display: inline;
103
+ }
104
+ }
105
+ </style>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Enlace visible solo al recibir foco por teclado.
4
+ * Debe ir al inicio del layout autenticado.
5
+ */
6
+ defineProps<{
7
+ targetId?: string
8
+ label?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <a class="skip-link" :href="`#${targetId ?? 'main-content'}`">
14
+ {{ label ?? 'Saltar al contenido' }}
15
+ </a>
16
+ </template>
17
+
18
+ <style scoped>
19
+ .skip-link {
20
+ position: absolute;
21
+ left: 0.75rem;
22
+ top: 0.75rem;
23
+ z-index: 100;
24
+ transform: translateY(-200%);
25
+ border-radius: 0.5rem;
26
+ background: var(--color-brand-700, #15803d);
27
+ padding: 0.625rem 1rem;
28
+ font-size: 0.875rem;
29
+ font-weight: 600;
30
+ color: #fff;
31
+ text-decoration: none;
32
+ box-shadow: 0 4px 14px rgb(0 0 0 / 0.2);
33
+ }
34
+
35
+ .skip-link:focus,
36
+ .skip-link:focus-visible {
37
+ transform: translateY(0);
38
+ outline: 3px solid #fff;
39
+ outline-offset: 2px;
40
+ }
41
+ </style>
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import softdinLogo from '../../assets/softdin-logo.png'
4
+ import { tenantLogoPublicUrl } from '../../utils/tenantLogo'
5
+
6
+ const props = defineProps<{
7
+ tenantName?: string
8
+ tenantSlug?: string
9
+ tenantHasLogo?: boolean
10
+ previewLogoUrl?: string | null
11
+ }>()
12
+
13
+ const showTenantBrand = computed(
14
+ () => Boolean(props.tenantName?.trim() && props.tenantSlug?.trim()),
15
+ )
16
+
17
+ const logoSrc = computed(() => {
18
+ if (props.previewLogoUrl) {
19
+ return props.previewLogoUrl
20
+ }
21
+
22
+ if (props.tenantHasLogo && props.tenantSlug) {
23
+ return tenantLogoPublicUrl(props.tenantSlug)
24
+ }
25
+
26
+ return null
27
+ })
28
+
29
+ const initials = computed(() => {
30
+ const name = props.tenantName?.trim() ?? ''
31
+ if (!name) {
32
+ return '?'
33
+ }
34
+
35
+ const parts = name.split(/\s+/).slice(0, 2)
36
+ return parts.map((part) => part[0]?.toUpperCase() ?? '').join('')
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <div v-if="showTenantBrand" class="brand-row">
42
+ <span class="brand-logo-wrap">
43
+ <img
44
+ v-if="logoSrc"
45
+ :src="logoSrc"
46
+ :alt="tenantName"
47
+ class="brand-logo"
48
+ />
49
+ <span v-else class="brand-logo-fallback">{{ initials }}</span>
50
+ </span>
51
+ <div class="brand-text">
52
+ <span class="brand-title">{{ tenantName }}</span>
53
+ <span class="brand-subtitle">{{ tenantSlug }}</span>
54
+ </div>
55
+ </div>
56
+
57
+ <div v-else class="brand-row">
58
+ <img :src="softdinLogo" alt="SoftDIN Central" class="brand-logo" />
59
+ <div class="brand-text">
60
+ <span class="brand-title">SoftDIN Central</span>
61
+ </div>
62
+ </div>
63
+ </template>
64
+
65
+ <style scoped>
66
+ .brand-row {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 0.875rem;
70
+ }
71
+
72
+ .brand-text {
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: flex-start;
76
+ text-align: left;
77
+ }
78
+
79
+ .brand-title {
80
+ font-size: 1.375rem;
81
+ font-weight: 700;
82
+ line-height: 1.2;
83
+ color: var(--color-brand-800);
84
+ }
85
+
86
+ .brand-subtitle {
87
+ font-size: 0.8125rem;
88
+ font-weight: 600;
89
+ letter-spacing: 0.04em;
90
+ color: var(--color-brand-600);
91
+ text-transform: lowercase;
92
+ }
93
+
94
+ .brand-logo-wrap {
95
+ display: flex;
96
+ height: 5.5rem;
97
+ width: 5.5rem;
98
+ flex-shrink: 0;
99
+ align-items: center;
100
+ justify-content: center;
101
+ overflow: hidden;
102
+ border-radius: 9999px;
103
+ border: 3px solid var(--color-brand-100);
104
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
105
+ background: #f8fafc;
106
+ }
107
+
108
+ .brand-logo {
109
+ height: 100%;
110
+ width: 100%;
111
+ object-fit: contain;
112
+ }
113
+
114
+ .brand-logo-fallback {
115
+ font-size: 1.25rem;
116
+ font-weight: 700;
117
+ color: var(--color-brand-700);
118
+ }
119
+
120
+ .brand-row > .brand-logo {
121
+ width: 5.5rem;
122
+ height: 5.5rem;
123
+ border-radius: 9999px;
124
+ object-fit: cover;
125
+ object-position: center 18%;
126
+ border: 3px solid var(--color-brand-100);
127
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12);
128
+ }
129
+ </style>
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ import AuthBrandHeader from './AuthBrandHeader.vue'
3
+
4
+ defineProps<{
5
+ title: string
6
+ subtitle?: string
7
+ wide?: boolean
8
+ extraWide?: boolean
9
+ tenantName?: string
10
+ tenantSlug?: string
11
+ tenantHasLogo?: boolean
12
+ tenantPreviewLogoUrl?: string | null
13
+ }>()
14
+ </script>
15
+
16
+ <template>
17
+ <div class="auth-bg flex min-h-full items-center justify-center px-4 py-10">
18
+ <div
19
+ class="w-full rounded-2xl bg-white p-8 shadow-xl"
20
+ :class="extraWide ? 'max-w-2xl' : wide ? 'max-w-lg' : 'max-w-md'"
21
+ >
22
+ <div class="mb-8 flex flex-col items-center text-center">
23
+ <AuthBrandHeader
24
+ class="mb-3"
25
+ :tenant-name="tenantName"
26
+ :tenant-slug="tenantSlug"
27
+ :tenant-has-logo="tenantHasLogo"
28
+ :preview-logo-url="tenantPreviewLogoUrl"
29
+ />
30
+ <h1 class="text-xl font-bold text-slate-900">{{ title }}</h1>
31
+ <p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
32
+ </div>
33
+
34
+ <slot />
35
+
36
+ <div v-if="$slots.footer" class="mt-6 border-t border-slate-100 pt-4 text-center text-sm">
37
+ <slot name="footer" />
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .auth-bg {
45
+ background: linear-gradient(
46
+ 135deg,
47
+ var(--color-brand-800) 0%,
48
+ var(--color-brand-700) 45%,
49
+ #052e16 100%
50
+ );
51
+ }
52
+ </style>