kmcom-nuxt-layers 2.2.12 → 2.3.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.
Files changed (102) hide show
  1. package/docs/FEEDS.md +1 -2
  2. package/layers/animations/app/composables/useMagneticElement.ts +11 -9
  3. package/layers/animations/app/composables/useTiltEffect.ts +11 -9
  4. package/layers/animations/app/utils/pointerMotion.ts +31 -0
  5. package/layers/canvas/app/components/ShaderCanvas.vue +2 -2
  6. package/layers/content/app/composables/useCollectionItems.ts +28 -0
  7. package/layers/content/app/composables/useGalleryItems.ts +8 -14
  8. package/layers/content/app/composables/usePortfolioItems.ts +10 -18
  9. package/layers/core/app/composables/useBrowser.ts +9 -82
  10. package/layers/core/app/composables/useFeatures.ts +3 -27
  11. package/layers/core/app/plugins/init.ts +157 -135
  12. package/layers/core/app/utils/browserInfo.ts +115 -0
  13. package/layers/core/app/utils/featureClasses.ts +40 -0
  14. package/layers/core/app/utils/helpers.test.ts +51 -0
  15. package/layers/feeds/app/components/Feeds/Index.vue +1 -1
  16. package/layers/feeds/app/components/Feeds/RouteCard.vue +3 -9
  17. package/layers/feeds/app/utils/feed-catalog.ts +9 -4
  18. package/layers/feeds/nuxt.config.ts +0 -1
  19. package/layers/feeds/server/utils/content-adapter.test.ts +68 -0
  20. package/layers/feeds/server/utils/content-adapter.ts +2 -22
  21. package/layers/feeds/server/utils/feed-author.ts +32 -0
  22. package/layers/feeds/server/utils/feed-config.ts +88 -0
  23. package/layers/feeds/server/utils/feed-service.ts +11 -30
  24. package/layers/feeds/server/utils/feed-xml.ts +26 -0
  25. package/layers/feeds/server/utils/formats/rss.ts +10 -15
  26. package/layers/feeds/server/utils/formats.test.ts +71 -0
  27. package/layers/forms/app/components/Form/Field.vue +42 -30
  28. package/layers/forms/app/utils/fieldProps.ts +65 -0
  29. package/layers/layout/app/components/Layout/Grid/Item.vue +29 -146
  30. package/layers/layout/app/utils/gridPlacementStyle.ts +195 -0
  31. package/layers/mailer/app/types/mailer.ts +7 -25
  32. package/layers/mailer/server/utils/email.ts +28 -13
  33. package/layers/mailer/server/utils/hooks.ts +1 -20
  34. package/layers/navigation/app/composables/useSite.ts +2 -9
  35. package/layers/navigation/app/utils/site.ts +26 -0
  36. package/layers/routing/app/utils/resolveRoute.test.ts +47 -0
  37. package/layers/routing/app/utils/resolveRoute.ts +19 -10
  38. package/layers/scripts/app/composables/useAnalytics.ts +8 -41
  39. package/layers/scripts/app/composables/useGtm.ts +6 -13
  40. package/layers/scripts/app/utils/scriptClients.ts +70 -0
  41. package/layers/scroll/app/composables/useSmoothScroll.ts +9 -43
  42. package/layers/scroll/app/utils/scroll.ts +103 -0
  43. package/layers/seo/app/composables/useSeoConfig.ts +3 -9
  44. package/layers/seo/app/utils/seoConfig.ts +38 -0
  45. package/layers/shader/app/components/Material/AmbientAurora.client.vue +11 -33
  46. package/layers/shader/app/components/Material/AmbientFlow.client.vue +10 -37
  47. package/layers/shader/app/components/Material/AmbientGradientMesh.client.vue +10 -37
  48. package/layers/shader/app/components/Material/AmbientNebula.client.vue +12 -37
  49. package/layers/shader/app/components/Material/AmbientOcean.client.vue +9 -33
  50. package/layers/shader/app/components/Material/Gradient.client.vue +25 -46
  51. package/layers/shader/app/components/Material/Image.client.vue +10 -55
  52. package/layers/shader/app/components/Material/Node.client.vue +18 -5
  53. package/layers/shader/app/components/Material/Noise.client.vue +9 -43
  54. package/layers/shader/app/components/Preset/ThemeBubble.client.vue +2 -1
  55. package/layers/shader/app/components/Preset/ThemeFlow.client.vue +2 -1
  56. package/layers/shader/app/components/Preset/ThemeGradient.client.vue +2 -1
  57. package/layers/shader/app/components/Preset/ThemeLavaLamp.client.vue +2 -1
  58. package/layers/shader/app/components/Preset/ThemePlasma.client.vue +2 -1
  59. package/layers/shader/app/components/Preset/ThemeWave.client.vue +2 -1
  60. package/layers/shader/app/components/Shader/Background.client.vue +44 -24
  61. package/layers/shader/app/composables/useAmbientMaterials.ts +5 -1
  62. package/layers/shader/app/composables/useShader.ts +38 -23
  63. package/layers/shader/app/composables/useShaderGraph.ts +11 -6
  64. package/layers/shader/app/composables/useShaderMixBlend.ts +4 -4
  65. package/layers/shader/app/composables/useShaderRuntime.ts +0 -1
  66. package/layers/shader/app/composables/useShaderVec2.ts +2 -4
  67. package/layers/shader/app/composables/useThemePreset.ts +34 -8
  68. package/layers/shader/app/composables/useUniformWatchers.ts +15 -0
  69. package/layers/shader/app/composables/useUniforms.ts +0 -1
  70. package/layers/shader/app/shaders/common/blend.ts +4 -4
  71. package/layers/shader/app/shaders/common/effects.ts +38 -21
  72. package/layers/shader/app/shaders/common/grain.ts +46 -49
  73. package/layers/shader/app/shaders/common/lighting.ts +17 -15
  74. package/layers/shader/app/shaders/common/math.ts +2 -4
  75. package/layers/shader/app/shaders/common/nodes.ts +17 -0
  76. package/layers/shader/app/shaders/common/palette.ts +21 -11
  77. package/layers/shader/app/shaders/common/patterns.ts +25 -14
  78. package/layers/shader/app/shaders/common/shapes.ts +97 -88
  79. package/layers/shader/app/shaders/common/uv.ts +33 -34
  80. package/layers/shader/app/shaders/createMaterial.ts +92 -78
  81. package/layers/shader/app/shaders/layers/paperShading.ts +22 -10
  82. package/layers/shader/app/shaders/layers/shaderGradient.ts +46 -21
  83. package/layers/shader/app/utils/tsl/tween.ts +2 -4
  84. package/layers/shader/package.json +5 -1
  85. package/layers/starter/app/components/StarterDesignSystem.vue +1913 -0
  86. package/layers/starter/app/components/StarterHome.vue +407 -0
  87. package/layers/starter/nuxt.config.ts +15 -0
  88. package/layers/starter/package.json +10 -0
  89. package/layers/theme/app/components/ThemePicker/Menu.vue +3 -25
  90. package/layers/theme/app/composables/useThemePreferenceModels.ts +39 -0
  91. package/layers/theme/server/plugins/theme-fouc.ts +1 -92
  92. package/layers/theme/server/utils/accent-css.ts +75 -0
  93. package/layers/typography/app/composables/typography.ts +3 -7
  94. package/layers/visual/app/composables/accent.ts +2 -9
  95. package/layers/visual/app/composables/gradient.ts +33 -46
  96. package/layers/visual/app/composables/picture.ts +2 -79
  97. package/layers/visual/app/utils/colorTokens.ts +23 -0
  98. package/layers/visual/app/utils/gradientStyle.ts +41 -0
  99. package/layers/visual/app/utils/responsiveSizes.ts +49 -0
  100. package/package.json +17 -5
  101. package/layers/feeds/app/utils/feed-catalog.test.ts +0 -71
  102. package/layers/feeds/server/routes/feed/discovery.get.ts +0 -31
@@ -32,6 +32,11 @@
32
32
  * </BaseGridItem>
33
33
  */
34
34
 
35
+ import {
36
+ buildGridPlacementClasses,
37
+ buildGridPlacementStyle,
38
+ } from '#layers/layout/app/utils/gridPlacementStyle'
39
+
35
40
  type ColSpanValue = number | 'full'
36
41
  type Alignment = 'start' | 'center' | 'end' | 'stretch'
37
42
  type LayerName = 'back' | 'mid' | 'front' | 'top'
@@ -100,152 +105,30 @@
100
105
  const gap = computed(() => gapProp ?? presetConfig.value?.gap)
101
106
  const density = computed(() => densityProp ?? presetConfig.value?.density)
102
107
 
103
- const layerZIndex: Record<LayerName, number> = {
104
- back: 0,
105
- mid: 10,
106
- front: 20,
107
- top: 30,
108
- }
109
-
110
- const aspectClasses: Record<AspectRatio, string> = {
111
- '1/1': 'aspect-square',
112
- '4/3': 'aspect-[4/3]',
113
- '3/4': 'aspect-[3/4]',
114
- '16/9': 'aspect-video',
115
- '9/16': 'aspect-[9/16]',
116
- '2/1': 'aspect-[2/1]',
117
- '1/2': 'aspect-[1/2]',
118
- }
119
-
120
- const getDefaultValue = <T,>(value: T | ResponsiveValue<T> | undefined, defaultVal: T): T => {
121
- if (value === undefined) return defaultVal
122
- if (typeof value === 'object' && value !== null && 'default' in value) {
123
- return value.default
124
- }
125
- return value as T
126
- }
127
-
128
- const getResponsiveValue = <T,>(
129
- value: T | ResponsiveValue<T> | undefined,
130
- breakpoint: 'md' | 'lg'
131
- ): T | undefined => {
132
- if (value === undefined) return undefined
133
- if (typeof value === 'object' && value !== null && 'default' in value) {
134
- return value[breakpoint]
135
- }
136
- return undefined
137
- }
138
-
139
- const style = computed(() => {
140
- const styles: Record<string, string> = {}
141
-
142
- const colStartVal = getDefaultValue(colStart.value, undefined)
143
- const colSpanVal = getDefaultValue(
144
- colSpan.value as ColSpanValue | ResponsiveValue<number> | undefined,
145
- 'full' as ColSpanValue
146
- )
147
- const rowStartVal = getDefaultValue(rowStart.value, undefined)
148
- const rowSpanVal = getDefaultValue(rowSpan.value, 1)
149
-
150
- if (bleed) {
151
- if (bleed === 'both') {
152
- styles.gridColumn = '1 / -1'
153
- styles.marginInline = 'calc(-1 * var(--grid-padding))'
154
- } else if (bleed === 'left') {
155
- const spanNum = typeof colSpanVal === 'number' ? colSpanVal : undefined
156
- styles.gridColumn = spanNum ? `1 / span ${spanNum}` : '1 / -1'
157
- styles.marginInlineStart = 'calc(-1 * var(--grid-padding))'
158
- } else if (bleed === 'right') {
159
- styles.gridColumn = `${colStartVal ?? 'auto'} / -1`
160
- styles.marginInlineEnd = 'calc(-1 * var(--grid-padding))'
161
- }
162
- styles.gridRow = `${rowStartVal ?? 'auto'} / span ${rowSpanVal}`
163
- } else if (colSpanVal === 'full') {
164
- // 'full' span: use inline gridColumn directly (no CSS var approach needed)
165
- styles.gridColumn = `${colStartVal ?? 1} / -1`
166
- // Still set row vars for responsive row support
167
- styles['--_rs'] = String(rowStartVal ?? 'auto')
168
- styles['--_re'] = String(rowSpanVal)
169
-
170
- const mdRowStart = getResponsiveValue(rowStart.value, 'md')
171
- const lgRowStart = getResponsiveValue(rowStart.value, 'lg')
172
- const mdRowSpan = getResponsiveValue(rowSpan.value, 'md')
173
- const lgRowSpan = getResponsiveValue(rowSpan.value, 'lg')
174
-
175
- if (mdRowStart !== undefined) styles['--_md-rs'] = String(mdRowStart)
176
- if (lgRowStart !== undefined) styles['--_lg-rs'] = String(lgRowStart)
177
- if (mdRowSpan !== undefined) styles['--_md-re'] = String(mdRowSpan)
178
- if (lgRowSpan !== undefined) styles['--_lg-re'] = String(lgRowSpan)
179
- } else {
180
- // Set CSS custom properties instead of grid-column/grid-row directly.
181
- // The <style> block below reads these vars and applies them at each breakpoint,
182
- // which correctly cascades without inline-style specificity conflicts.
183
- styles['--_cs'] = String(colStartVal ?? 'auto')
184
- styles['--_ce'] = String(colSpanVal)
185
- styles['--_rs'] = String(rowStartVal ?? 'auto')
186
- styles['--_re'] = String(rowSpanVal)
187
-
188
- const mdColStart = getResponsiveValue(colStart.value, 'md')
189
- const lgColStart = getResponsiveValue(colStart.value, 'lg')
190
- const mdColSpan = getResponsiveValue(
191
- colSpan.value as ResponsiveValue<number> | undefined,
192
- 'md'
193
- )
194
- const lgColSpan = getResponsiveValue(
195
- colSpan.value as ResponsiveValue<number> | undefined,
196
- 'lg'
197
- )
198
- const mdRowStart = getResponsiveValue(rowStart.value, 'md')
199
- const lgRowStart = getResponsiveValue(rowStart.value, 'lg')
200
- const mdRowSpan = getResponsiveValue(rowSpan.value, 'md')
201
- const lgRowSpan = getResponsiveValue(rowSpan.value, 'lg')
202
-
203
- if (mdColStart !== undefined) styles['--_md-cs'] = String(mdColStart)
204
- if (lgColStart !== undefined) styles['--_lg-cs'] = String(lgColStart)
205
- if (mdColSpan !== undefined) styles['--_md-ce'] = String(mdColSpan)
206
- if (lgColSpan !== undefined) styles['--_lg-ce'] = String(lgColSpan)
207
- if (mdRowStart !== undefined) styles['--_md-rs'] = String(mdRowStart)
208
- if (lgRowStart !== undefined) styles['--_lg-rs'] = String(lgRowStart)
209
- if (mdRowSpan !== undefined) styles['--_md-re'] = String(mdRowSpan)
210
- if (lgRowSpan !== undefined) styles['--_lg-re'] = String(lgRowSpan)
211
- }
212
-
213
- // Content alignment
214
- if (align.value || justify.value) {
215
- styles.display = 'grid'
216
- styles.width = '100%'
217
- styles.height = '100%'
218
- styles.placeItems = `${align.value ?? 'stretch'} ${justify.value ?? 'stretch'}`
219
- }
220
-
221
- // Gap override — cascades to nested content via --grid-gap
222
- if (gap.value) styles['--grid-gap'] = gap.value
223
-
224
- // Density — sets --rhythm base unit for child spacing utilities
225
- if (density.value) {
226
- const rhythmMap: Record<Density, string> = {
227
- compact: '0.125rem',
228
- normal: '0.25rem',
229
- relaxed: '0.5rem',
230
- }
231
- styles['--rhythm'] = rhythmMap[density.value]
232
- }
233
-
234
- // Z-index
235
- const zIndex = z ?? (layer ? layerZIndex[layer] : undefined)
236
- if (zIndex !== undefined) styles.zIndex = String(zIndex)
237
-
238
- return styles
239
- })
240
-
241
- const classes = computed(() => {
242
- const classList: string[] = ['gi-placed', '@container', '@container/item']
243
-
244
- if (aspect) classList.push(aspectClasses[aspect])
245
- if (container.value) classList.push(`layout-container-${container.value}`)
246
-
247
- return classList.join(' ')
248
- })
108
+ const style = computed(() =>
109
+ buildGridPlacementStyle({
110
+ colStart: colStart.value,
111
+ colSpan: colSpan.value as ColSpanValue | ResponsiveValue<number> | undefined,
112
+ rowStart: rowStart.value,
113
+ rowSpan: rowSpan.value,
114
+ align: align.value,
115
+ justify: justify.value,
116
+ container: container.value,
117
+ gap: gap.value,
118
+ density: density.value,
119
+ z,
120
+ layer,
121
+ bleed,
122
+ aspect,
123
+ })
124
+ )
125
+
126
+ const classes = computed(() =>
127
+ buildGridPlacementClasses({
128
+ aspect,
129
+ container: container.value,
130
+ })
131
+ )
249
132
  </script>
250
133
 
251
134
  <template>
@@ -0,0 +1,195 @@
1
+ type ResponsiveValue<T> = {
2
+ default: T
3
+ md?: T
4
+ lg?: T
5
+ }
6
+
7
+ type Alignment = 'start' | 'center' | 'end' | 'stretch'
8
+ type LayerName = 'back' | 'mid' | 'front' | 'top'
9
+ type BleedDirection = 'left' | 'right' | 'both'
10
+ type AspectRatio = '1/1' | '4/3' | '3/4' | '16/9' | '9/16' | '2/1' | '1/2'
11
+ type ContainerSize = 'content' | 'wide' | 'fluid' | 'full'
12
+ type Density = 'compact' | 'normal' | 'relaxed'
13
+
14
+ export type GridPlacementInput = {
15
+ colStart?: number | ResponsiveValue<number> | undefined
16
+ colSpan?: number | 'full' | ResponsiveValue<number> | undefined
17
+ rowStart?: number | ResponsiveValue<number> | undefined
18
+ rowSpan?: number | ResponsiveValue<number> | undefined
19
+ align?: Alignment | undefined
20
+ justify?: Alignment | undefined
21
+ container?: ContainerSize | undefined
22
+ gap?: string | undefined
23
+ density?: Density | undefined
24
+ z?: number | undefined
25
+ layer?: LayerName | undefined
26
+ bleed?: BleedDirection | undefined
27
+ aspect?: AspectRatio | undefined
28
+ }
29
+
30
+ type PlacementStyle = Record<string, string>
31
+
32
+ export const layerZIndex: Record<LayerName, number> = {
33
+ back: 0,
34
+ mid: 10,
35
+ front: 20,
36
+ top: 30,
37
+ }
38
+
39
+ export const aspectClasses: Record<AspectRatio, string> = {
40
+ '1/1': 'aspect-square',
41
+ '4/3': 'aspect-[4/3]',
42
+ '3/4': 'aspect-[3/4]',
43
+ '16/9': 'aspect-video',
44
+ '9/16': 'aspect-[9/16]',
45
+ '2/1': 'aspect-[2/1]',
46
+ '1/2': 'aspect-[1/2]',
47
+ }
48
+
49
+ // fallow-ignore-next-line complexity
50
+ export function resolveDefaultPlacement<T>(
51
+ value: T | ResponsiveValue<T> | undefined,
52
+ fallback: T | undefined
53
+ ): T | undefined {
54
+ if (value === undefined) return fallback
55
+ if (typeof value === 'object' && value !== null && 'default' in value) {
56
+ return value.default
57
+ }
58
+ return value
59
+ }
60
+
61
+ // fallow-ignore-next-line complexity
62
+ function resolveResponsivePlacementValue<T>(
63
+ value: T | ResponsiveValue<T> | undefined,
64
+ breakpoint: 'md' | 'lg'
65
+ ): T | undefined {
66
+ if (value === undefined) return undefined
67
+ if (typeof value === 'object' && value !== null && 'default' in value) {
68
+ return value[breakpoint]
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ export function resolveResponsivePlacementVars<T>(
74
+ value: T | ResponsiveValue<T> | undefined,
75
+ prefix: string
76
+ ): PlacementStyle {
77
+ const styles: PlacementStyle = {}
78
+ const mdValue = resolveResponsivePlacementValue(value, 'md')
79
+ const lgValue = resolveResponsivePlacementValue(value, 'lg')
80
+
81
+ if (mdValue !== undefined) styles[`--_md-${prefix}`] = String(mdValue)
82
+ if (lgValue !== undefined) styles[`--_lg-${prefix}`] = String(lgValue)
83
+
84
+ return styles
85
+ }
86
+
87
+ // fallow-ignore-next-line complexity
88
+ export function resolveBleedStyles(args: {
89
+ bleed?: BleedDirection | undefined
90
+ colStart?: number | undefined
91
+ colSpan?: number | 'full' | undefined
92
+ rowStart?: number | undefined
93
+ rowSpan?: number | undefined
94
+ }): PlacementStyle {
95
+ const styles: PlacementStyle = {}
96
+ const { bleed, colStart, colSpan, rowStart, rowSpan } = args
97
+
98
+ if (!bleed) return styles
99
+
100
+ if (bleed === 'both') {
101
+ styles.gridColumn = '1 / -1'
102
+ styles.marginInline = 'calc(-1 * var(--grid-padding))'
103
+ } else if (bleed === 'left') {
104
+ const spanNum = typeof colSpan === 'number' ? colSpan : undefined
105
+ styles.gridColumn = spanNum ? `1 / span ${spanNum}` : '1 / -1'
106
+ styles.marginInlineStart = 'calc(-1 * var(--grid-padding))'
107
+ } else if (bleed === 'right') {
108
+ styles.gridColumn = `${colStart ?? 'auto'} / -1`
109
+ styles.marginInlineEnd = 'calc(-1 * var(--grid-padding))'
110
+ }
111
+
112
+ styles.gridRow = `${rowStart ?? 'auto'} / span ${rowSpan}`
113
+ return styles
114
+ }
115
+
116
+ // fallow-ignore-next-line complexity
117
+ export function resolveAlignmentStyles(align?: Alignment, justify?: Alignment): PlacementStyle {
118
+ if (!align && !justify) return {}
119
+ return {
120
+ display: 'grid',
121
+ width: '100%',
122
+ height: '100%',
123
+ placeItems: `${align ?? 'stretch'} ${justify ?? 'stretch'}`,
124
+ }
125
+ }
126
+
127
+ export function resolveRhythmStyles(density?: Density): PlacementStyle {
128
+ if (!density) return {}
129
+
130
+ const rhythmMap: Record<Density, string> = {
131
+ compact: '0.125rem',
132
+ normal: '0.25rem',
133
+ relaxed: '0.5rem',
134
+ }
135
+
136
+ return { '--rhythm': rhythmMap[density] }
137
+ }
138
+
139
+ export function resolveLayerStyles(z?: number, layer?: LayerName): PlacementStyle {
140
+ const zIndex = z ?? (layer ? layerZIndex[layer] : undefined)
141
+ return zIndex !== undefined ? { zIndex: String(zIndex) } : {}
142
+ }
143
+
144
+ // fallow-ignore-next-line complexity
145
+ export function buildGridPlacementStyle(input: GridPlacementInput): PlacementStyle {
146
+ const style: PlacementStyle = {}
147
+ const colStart = resolveDefaultPlacement(input.colStart, undefined)
148
+ const colSpan = resolveDefaultPlacement(input.colSpan, 'full')
149
+ const rowStart = resolveDefaultPlacement(input.rowStart, undefined)
150
+ const rowSpan = resolveDefaultPlacement(input.rowSpan, 1)
151
+
152
+ Object.assign(style, resolveBleedStyles({
153
+ bleed: input.bleed,
154
+ colStart,
155
+ colSpan,
156
+ rowStart,
157
+ rowSpan,
158
+ }))
159
+
160
+ if (!input.bleed) {
161
+ if (colSpan === 'full') {
162
+ style.gridColumn = `${colStart ?? 1} / -1`
163
+ style['--_rs'] = String(rowStart ?? 'auto')
164
+ style['--_re'] = String(rowSpan)
165
+ Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
166
+ Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
167
+ } else {
168
+ style['--_cs'] = String(colStart ?? 'auto')
169
+ style['--_ce'] = String(colSpan)
170
+ style['--_rs'] = String(rowStart ?? 'auto')
171
+ style['--_re'] = String(rowSpan)
172
+ Object.assign(style, resolveResponsivePlacementVars(input.colStart, 'cs'))
173
+ Object.assign(style, resolveResponsivePlacementVars(input.colSpan, 'ce'))
174
+ Object.assign(style, resolveResponsivePlacementVars(input.rowStart, 'rs'))
175
+ Object.assign(style, resolveResponsivePlacementVars(input.rowSpan, 're'))
176
+ }
177
+ }
178
+
179
+ Object.assign(style, resolveAlignmentStyles(input.align, input.justify))
180
+ Object.assign(style, resolveRhythmStyles(input.density))
181
+ Object.assign(style, resolveLayerStyles(input.z, input.layer))
182
+
183
+ if (input.gap) style['--grid-gap'] = input.gap
184
+
185
+ return style
186
+ }
187
+
188
+ export function buildGridPlacementClasses(input: Pick<GridPlacementInput, 'aspect' | 'container'>) {
189
+ const classList: string[] = ['gi-placed', '@container', '@container/item']
190
+
191
+ if (input.aspect) classList.push(aspectClasses[input.aspect])
192
+ if (input.container) classList.push(`layout-container-${input.container}`)
193
+
194
+ return classList.join(' ')
195
+ }
@@ -1,25 +1,7 @@
1
- export type ContactEmailData = {
2
- name: string
3
- email: string
4
- message: string
5
- }
6
-
7
- export type ContactSubmittedPayload = {
8
- name: string
9
- email: string
10
- message: string
11
- }
12
-
13
- export type ContactSentPayload = {
14
- messageId: string
15
- } & ContactSubmittedPayload
16
-
17
- export type ContactFailedPayload = {
18
- error: unknown
19
- } & ContactSubmittedPayload
20
-
21
- export type MailerLayerHooks = {
22
- 'contact:submitted': (payload: ContactSubmittedPayload) => void
23
- 'contact:sent': (payload: ContactSentPayload) => void
24
- 'contact:failed': (payload: ContactFailedPayload) => void
25
- }
1
+ export type {
2
+ ContactEmailData,
3
+ ContactFailedPayload,
4
+ ContactSentPayload,
5
+ ContactSubmittedPayload,
6
+ MailerLayerHooks,
7
+ } from '#layers/mailer/shared/contact'
@@ -1,27 +1,42 @@
1
1
  import { Resend } from 'resend'
2
2
 
3
- export type ContactEmailData = {
4
- name: string
5
- email: string
6
- message: string
3
+ import type { ContactEmailData } from '#layers/mailer/shared/contact'
4
+
5
+ type MailerTransport = {
6
+ from: string
7
+ to: string
7
8
  }
8
9
 
9
- export async function sendContactEmail(data: ContactEmailData) {
10
+ function resolveMailerTransport() {
10
11
  const { resendApiKey, emailFrom, emailTo } = useMailerConfig()
12
+ if (!resendApiKey) return null
11
13
 
12
- if (!resendApiKey) {
13
- console.warn('[mailer] NUXT_MAILER_LAYER_RESEND_API_KEY not set — email skipped')
14
- return { success: false as const, error: 'No API key configured' }
15
- }
16
-
17
- const resend = new Resend(resendApiKey)
18
- const { data: result, error } = await resend.emails.send({
14
+ return {
15
+ apiKey: resendApiKey,
19
16
  from: emailFrom ?? '',
20
17
  to: emailTo ?? '',
18
+ } satisfies MailerTransport & { apiKey: string }
19
+ }
20
+
21
+ function buildContactEmailPayload(data: ContactEmailData, transport: MailerTransport) {
22
+ return {
23
+ from: transport.from,
24
+ to: transport.to,
21
25
  replyTo: data.email,
22
26
  subject: `Contact form submission from ${data.name}`,
23
27
  text: `Name: ${data.name}\nEmail: ${data.email}\n\nMessage:\n${data.message}`,
24
- })
28
+ }
29
+ }
30
+
31
+ export async function sendContactEmail(data: ContactEmailData) {
32
+ const transport = resolveMailerTransport()
33
+ if (!transport) {
34
+ console.warn('[mailer] NUXT_MAILER_LAYER_RESEND_API_KEY not set — email skipped')
35
+ return { success: false as const, error: 'No API key configured' }
36
+ }
37
+
38
+ const resend = new Resend(transport.apiKey)
39
+ const { data: result, error } = await resend.emails.send(buildContactEmailPayload(data, transport))
25
40
 
26
41
  if (error) return { success: false as const, error }
27
42
  return { success: true as const, messageId: result?.id ?? '' }
@@ -1,23 +1,4 @@
1
1
  import { createHooks } from 'hookable'
2
-
3
- export type ContactSubmittedPayload = {
4
- name: string
5
- email: string
6
- message: string
7
- }
8
-
9
- export type ContactSentPayload = {
10
- messageId: string
11
- } & ContactSubmittedPayload
12
-
13
- export type ContactFailedPayload = {
14
- error: unknown
15
- } & ContactSubmittedPayload
16
-
17
- export type MailerLayerHooks = {
18
- 'contact:submitted': (payload: ContactSubmittedPayload) => void
19
- 'contact:sent': (payload: ContactSentPayload) => void
20
- 'contact:failed': (payload: ContactFailedPayload) => void
21
- }
2
+ import type { MailerLayerHooks } from '#layers/mailer/shared/contact'
22
3
 
23
4
  export const mailerLayerHooks = createHooks<MailerLayerHooks>()
@@ -1,13 +1,6 @@
1
- import { splitSpaces } from '../utils/regex'
1
+ import { resolveSiteConfig } from '../utils/site'
2
2
 
3
3
  export function useSite() {
4
4
  const config = useAppConfig().site ?? {}
5
-
6
- return {
7
- title: (config.title ?? '') as string,
8
- titleWords: splitSpaces((config.title ?? '') as string) as string[],
9
- subtitle: (config.subtitle ?? '') as string,
10
- subtitleWords: splitSpaces((config.subtitle ?? '') as string) as string[],
11
- description: (config.description ?? '') as string,
12
- }
5
+ return resolveSiteConfig(config)
13
6
  }
@@ -0,0 +1,26 @@
1
+ import { splitSpaces } from '../utils/regex'
2
+
3
+ function resolveSiteText(value?: string) {
4
+ const text = value ?? ''
5
+ return {
6
+ text,
7
+ words: splitSpaces(text) as string[],
8
+ }
9
+ }
10
+
11
+ export function resolveSiteConfig(site: {
12
+ title?: string
13
+ subtitle?: string
14
+ description?: string
15
+ }) {
16
+ const title = resolveSiteText(site.title)
17
+ const subtitle = resolveSiteText(site.subtitle)
18
+
19
+ return {
20
+ title: title.text,
21
+ titleWords: title.words,
22
+ subtitle: subtitle.text,
23
+ subtitleWords: subtitle.words,
24
+ description: site.description ?? '',
25
+ }
26
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { FeatureValue, RouteResolution, RoutingLayerConfig } from '../types/routing'
4
+ import { resolveRoute } from './resolveRoute'
5
+
6
+ function createConfig(overrides: Partial<RoutingLayerConfig> = {}): RoutingLayerConfig {
7
+ return {
8
+ preset: 'marketing',
9
+ strictDefaultDeny: false,
10
+ layerDefaultDeny: false,
11
+ betaRedirect: '/coming-soon',
12
+ runtimeFlags: false,
13
+ debug: false,
14
+ maintenance: { enabled: false, allowRoutes: ['/maintenance'] },
15
+ scrollRouting: { enabled: false, mode: 'replace' },
16
+ features: {},
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('resolveRoute', () => {
22
+ it('allows routes without a feature by default', () => {
23
+ expect(resolveRoute({}, createConfig(), () => 'enabled')).toEqual({ outcome: 'allow' })
24
+ })
25
+
26
+ it('denies routes when strict default deny is enabled', () => {
27
+ expect(resolveRoute({}, createConfig({ strictDefaultDeny: true }), () => 'enabled')).toEqual({
28
+ outcome: 'deny',
29
+ })
30
+ })
31
+
32
+ it('denies layer-owned routes without a feature when layer default deny is enabled', () => {
33
+ expect(
34
+ resolveRoute({ __fromLayer: true }, createConfig({ layerDefaultDeny: true }), () => 'enabled')
35
+ ).toEqual({ outcome: 'deny' })
36
+ })
37
+
38
+ const featureCases: Array<[FeatureValue, RouteResolution]> = [
39
+ ['disabled', { outcome: 'deny' }],
40
+ ['beta', { outcome: 'redirect', to: '/coming-soon' }],
41
+ ['coming-soon', { outcome: 'redirect', to: '/coming-soon' }],
42
+ ]
43
+
44
+ it.each(featureCases)('handles %s feature variants', (variant, expected) => {
45
+ expect(resolveRoute({ feature: 'feature-x' }, createConfig(), () => variant)).toEqual(expected)
46
+ })
47
+ })
@@ -5,27 +5,36 @@ type RouteMeta = {
5
5
  __fromLayer?: boolean
6
6
  }
7
7
 
8
+ function shouldDenyStrict(meta: RouteMeta, config: RoutingLayerConfig): boolean {
9
+ return config.strictDefaultDeny && !meta.feature
10
+ }
11
+
12
+ function shouldDenyLayer(meta: RouteMeta, config: RoutingLayerConfig): boolean {
13
+ return config.layerDefaultDeny && meta.__fromLayer === true && !meta.feature
14
+ }
15
+
16
+ function resolveFeatureOutcome(variant: FeatureValue, config: RoutingLayerConfig): RouteResolution {
17
+ if (variant === 'disabled') return { outcome: 'deny' }
18
+ if (variant === 'beta' || variant === 'coming-soon') {
19
+ return { outcome: 'redirect', to: config.betaRedirect }
20
+ }
21
+ return { outcome: 'allow' }
22
+ }
23
+
8
24
  export function resolveRoute(
9
25
  meta: RouteMeta,
10
26
  config: RoutingLayerConfig,
11
27
  resolveFeature: (name: string) => FeatureValue
12
28
  ): RouteResolution {
13
- if (config.strictDefaultDeny && !meta.feature) {
29
+ if (shouldDenyStrict(meta, config)) {
14
30
  return { outcome: 'deny' }
15
31
  }
16
32
 
17
- if (config.layerDefaultDeny && meta.__fromLayer && !meta.feature) {
33
+ if (shouldDenyLayer(meta, config)) {
18
34
  return { outcome: 'deny' }
19
35
  }
20
36
 
21
37
  if (!meta.feature) return { outcome: 'allow' }
22
38
 
23
- const variant = resolveFeature(meta.feature)
24
-
25
- if (variant === 'disabled') return { outcome: 'deny' }
26
- if (variant === 'beta' || variant === 'coming-soon') {
27
- return { outcome: 'redirect', to: config.betaRedirect }
28
- }
29
-
30
- return { outcome: 'allow' }
39
+ return resolveFeatureOutcome(resolveFeature(meta.feature), config)
31
40
  }