kmcom-nuxt-layers 2.6.2 → 2.7.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 (114) hide show
  1. package/docs/ARCHITECTURE.md +21 -17
  2. package/docs/FEEDS.md +42 -34
  3. package/docs/IMPROVE-AUDIT-README.md +9 -10
  4. package/docs/IMPROVE-AUDIT-RESULTS.md +16 -17
  5. package/docs/IMPROVE-DEEP-AUDIT-RESULTS.md +29 -29
  6. package/docs/LAYERS-FIXES.md +1 -0
  7. package/docs/LAYOUT.md +91 -86
  8. package/docs/MIGRATION.md +93 -60
  9. package/docs/UPAGE-GRID-INTEGRATION.md +12 -10
  10. package/docs/USAGE.md +29 -38
  11. package/layers/animations/app/utils/pointerMotion.ts +1 -4
  12. package/layers/baseline/app/components/BaselineStatus.vue +13 -13
  13. package/layers/baseline/app/plugins/baseline-status.client.ts +4 -1
  14. package/layers/canvas/app/composables/useRendererCapabilities.ts +2 -2
  15. package/layers/content/app/components/Blog/Card.vue +1 -0
  16. package/layers/content/app/components/Gallery/Card.vue +1 -1
  17. package/layers/content/app/components/Gallery/Detail.vue +8 -1
  18. package/layers/content/app/components/Portfolio/Card.vue +1 -0
  19. package/layers/content/app/components/Portfolio/ColorPalette.vue +8 -1
  20. package/layers/content/app/components/Portfolio/Typography.vue +12 -2
  21. package/layers/content/app/composables/useGalleryItems.ts +3 -2
  22. package/layers/content/app/composables/usePortfolioItems.ts +3 -2
  23. package/layers/core/app/components/DiagnosticsPage.vue +15 -3
  24. package/layers/core/app/utils/featureClasses.test.ts +1 -3
  25. package/layers/core/app/utils/featureClasses.ts +1 -3
  26. package/layers/core/app/utils/helpers.test.ts +6 -2
  27. package/layers/core/nuxt.config.ts +2 -0
  28. package/layers/feeds/app/assets/css/feeds.css +3 -2
  29. package/layers/feeds/app/components/Feeds/Index.vue +36 -6
  30. package/layers/feeds/app/components/Feeds/RouteCard.vue +1 -3
  31. package/layers/feeds/app/plugins/feed-head.ts +2 -2
  32. package/layers/feeds/package.json +1 -1
  33. package/layers/feeds/public/feed/style.css +9 -9
  34. package/layers/feeds/server/utils/content-adapter.test.ts +3 -4
  35. package/layers/feeds/server/utils/content-adapter.ts +1 -1
  36. package/layers/feeds/server/utils/feed-author.test.ts +3 -3
  37. package/layers/feeds/server/utils/feed-config.test.ts +28 -6
  38. package/layers/feeds/server/utils/feed-config.ts +1 -4
  39. package/layers/feeds/server/utils/feed-service.ts +1 -1
  40. package/layers/feeds/server/utils/formats/rss.ts +1 -1
  41. package/layers/feeds/server/utils/formats.test.ts +1 -1
  42. package/layers/forms/app/utils/fieldProps.ts +1 -2
  43. package/layers/forms/server/api/contact.post.ts +4 -1
  44. package/layers/layout/app/assets/css/layout/grids.css +12 -3
  45. package/layers/layout/app/assets/css/layout/modes/fluid.css +9 -0
  46. package/layers/layout/app/components/Layout/Section/Sidebar.vue +11 -4
  47. package/layers/layout/app/utils/gridPlacementStyle.test.ts +3 -9
  48. package/layers/layout/app/utils/gridPlacementStyle.ts +10 -7
  49. package/layers/mailer/server/utils/email.ts +4 -3
  50. package/layers/mailer/server/utils/hooks.ts +1 -1
  51. package/layers/metadata/app/composables/useMetadataItem.ts +6 -2
  52. package/layers/metadata/app/composables/useMetadataSearch.ts +6 -2
  53. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +5 -5
  54. package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +11 -3
  55. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +9 -6
  56. package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +6 -3
  57. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +3 -1
  58. package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +4 -2
  59. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +1 -3
  60. package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +3 -1
  61. package/layers/metadata/server/api/metadata/status.get.ts +13 -18
  62. package/layers/metadata/server/utils/metadata/provider-registry.test.ts +1 -1
  63. package/layers/metadata/server/utils/metadata/search.test.ts +18 -18
  64. package/layers/metadata/server/utils/metadata/search.ts +16 -8
  65. package/layers/navigation/app/utils/site.test.ts +1 -3
  66. package/layers/scroll/app/components/Motion/PinnedSection.vue +11 -1
  67. package/layers/scroll/app/composables/useSectionProgress.ts +1 -1
  68. package/layers/scroll/app/plugins/locomotive-scroll.client.ts +6 -0
  69. package/layers/scroll/app/utils/scroll.ts +5 -1
  70. package/layers/seo/app/utils/seoConfig.test.ts +1 -1
  71. package/layers/shader/app/assets/css/shader.css +1 -1
  72. package/layers/shader/app/components/Material/AmbientAurora.client.vue +16 -4
  73. package/layers/shader/app/components/Material/AmbientFlow.client.vue +21 -5
  74. package/layers/shader/app/components/Material/AmbientGradientMesh.client.vue +21 -5
  75. package/layers/shader/app/components/Material/AmbientNebula.client.vue +21 -5
  76. package/layers/shader/app/components/Material/AmbientOcean.client.vue +16 -4
  77. package/layers/shader/app/components/Material/Gradient.client.vue +1 -1
  78. package/layers/shader/app/components/Material/Image.client.vue +1 -1
  79. package/layers/shader/app/components/Material/Noise.client.vue +11 -3
  80. package/layers/shader/app/composables/useAmbientMaterials.ts +0 -1
  81. package/layers/shader/app/shaders/common/blend.ts +5 -1
  82. package/layers/shader/app/shaders/common/grain.ts +5 -1
  83. package/layers/shader/app/shaders/common/lighting.ts +1 -6
  84. package/layers/shader/app/shaders/common/noise.ts +3 -1
  85. package/layers/shader/app/shaders/common/patterns.ts +1 -1
  86. package/layers/shader/app/shaders/common/shapes.ts +5 -1
  87. package/layers/shader/app/shaders/common/uv.ts +19 -2
  88. package/layers/shader/app/shaders/createMaterial.ts +11 -6
  89. package/layers/shader/app/shaders/layers/shaderGradient.ts +2 -12
  90. package/layers/shader/app/shaders/types.ts +1 -1
  91. package/layers/shader/app/types/uniforms.ts +1 -0
  92. package/layers/shader/app/utils/tsl/animation.ts +34 -36
  93. package/layers/starter/app/components/StarterDesignSystem.vue +120 -120
  94. package/layers/starter/app/components/StarterHome.vue +233 -208
  95. package/layers/theme/app/assets/css/theme.css +1 -1
  96. package/layers/theme/server/utils/accent-css.test.ts +17 -2
  97. package/layers/theme/server/utils/fouc-config.test.ts +7 -3
  98. package/layers/theme/server/utils/fouc-config.ts +24 -12
  99. package/layers/transitions/app/assets/css/transitions.css +6 -0
  100. package/layers/typography/app/assets/css/typography.css +313 -0
  101. package/layers/typography/app/components/Typography/Headline.vue +23 -14
  102. package/layers/typography/app/components/Typography/HeadlineScreen.vue +5 -12
  103. package/layers/typography/app/components/Typography/index.vue +4 -0
  104. package/layers/typography/app/composables/typography.ts +45 -5
  105. package/layers/typography/app/types/typography.ts +36 -7
  106. package/layers/typography/nuxt.config.ts +2 -0
  107. package/layers/visual/app/app.config.ts +1 -1
  108. package/layers/visual/app/assets/css/visual.css +1 -1
  109. package/layers/visual/app/composables/accent.ts +1 -2
  110. package/layers/visual/app/composables/tint.ts +1 -2
  111. package/layers/visual/app/types/media.ts +2 -2
  112. package/layers/visual/app/utils/gradientStyle.test.ts +1 -2
  113. package/layers/visual/app/utils/responsiveSizes.ts +0 -1
  114. package/package.json +36 -16
@@ -12,16 +12,16 @@
12
12
  }
13
13
 
14
14
  :root {
15
- --feed-bg: var(--ui-bg, #ffffff);
15
+ --feed-bg: var(--ui-bg, #fff);
16
16
  --feed-bg-elevated: var(--ui-bg-elevated, #f8fafc);
17
17
  --feed-bg-accented: var(--ui-bg-accented, #f1f5f9);
18
18
  --feed-border: var(--ui-border, #e2e8f0);
19
19
  --feed-text: var(--ui-text, #0f172a);
20
20
  --feed-text-muted: var(--ui-text-muted, #64748b);
21
21
  --feed-text-toned: var(--ui-text-toned, #475569);
22
- --feed-primary: var(--ui-color-primary-500, oklch(62.3% 0.214 259.5));
23
- --feed-primary-text: var(--ui-color-primary-600, oklch(54.6% 0.214 259.5));
24
- --feed-primary-bg: var(--ui-color-primary-50, oklch(97% 0.03 259.5));
22
+ --feed-primary: var(--ui-color-primary-500, oklch(62.3% 0.214 259.5deg));
23
+ --feed-primary-text: var(--ui-color-primary-600, oklch(54.6% 0.214 259.5deg));
24
+ --feed-primary-bg: var(--ui-color-primary-50, oklch(97% 0.03 259.5deg));
25
25
  --feed-radius: var(--ui-radius, 0.375rem);
26
26
  --feed-font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, sans-serif);
27
27
  }
@@ -35,9 +35,9 @@
35
35
  --feed-text: var(--ui-text, #f8fafc);
36
36
  --feed-text-muted: var(--ui-text-muted, #94a3b8);
37
37
  --feed-text-toned: var(--ui-text-toned, #cbd5e1);
38
- --feed-primary: var(--ui-color-primary-400, oklch(71% 0.18 259.5));
39
- --feed-primary-text: var(--ui-color-primary-400, oklch(71% 0.18 259.5));
40
- --feed-primary-bg: var(--ui-color-primary-950, oklch(22% 0.06 259.5));
38
+ --feed-primary: var(--ui-color-primary-400, oklch(71% 0.18 259.5deg));
39
+ --feed-primary-text: var(--ui-color-primary-400, oklch(71% 0.18 259.5deg));
40
+ --feed-primary-bg: var(--ui-color-primary-950, oklch(22% 0.06 259.5deg));
41
41
  }
42
42
  }
43
43
 
@@ -160,7 +160,7 @@ a:hover {
160
160
  border-radius: var(--feed-radius);
161
161
  background-color: var(--feed-primary);
162
162
  padding: 0.5rem 1.125rem;
163
- color: #ffffff;
163
+ color: #fff;
164
164
  font-weight: 600;
165
165
  font-size: 0.875rem;
166
166
  line-height: 1;
@@ -169,7 +169,7 @@ a:hover {
169
169
 
170
170
  .feed-subscribe:hover {
171
171
  opacity: 0.85;
172
- color: #ffffff;
172
+ color: #fff;
173
173
  text-decoration: none;
174
174
  }
175
175
 
@@ -1,6 +1,7 @@
1
- import { describe, expect, it, beforeEach, vi } from 'vitest'
2
-
3
1
  import type { H3Event } from 'h3'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { getContentFeedItems } from './content-adapter'
4
5
 
5
6
  const queryCollectionMock = vi.hoisted(() => vi.fn())
6
7
 
@@ -8,8 +9,6 @@ vi.mock('@nuxt/content/nitro', () => ({
8
9
  queryCollection: queryCollectionMock,
9
10
  }))
10
11
 
11
- import { getContentFeedItems } from './content-adapter'
12
-
13
12
  describe('getContentFeedItems', () => {
14
13
  beforeEach(() => {
15
14
  queryCollectionMock.mockReset()
@@ -1,8 +1,8 @@
1
1
  import { queryCollection } from '@nuxt/content/nitro'
2
2
  import type { H3Event } from 'h3'
3
3
 
4
- import type { FeedItem } from './types'
5
4
  import { resolveFeedAuthor, resolveFeedDate } from './feed-author'
5
+ import type { FeedItem } from './types'
6
6
 
7
7
  type FeedSourceAuthor = {
8
8
  name?: string | undefined
@@ -25,9 +25,9 @@ describe('resolveFeedAuthor', () => {
25
25
  })
26
26
 
27
27
  it('ignores an authors array entry with an empty name and falls through', () => {
28
- expect(
29
- resolveFeedAuthor({ authors: [{ name: '' }], author: 'Kieran Mansfield' })
30
- ).toBe('Kieran Mansfield')
28
+ expect(resolveFeedAuthor({ authors: [{ name: '' }], author: 'Kieran Mansfield' })).toBe(
29
+ 'Kieran Mansfield'
30
+ )
31
31
  })
32
32
 
33
33
  it('ignores an empty string author and falls through to object author', () => {
@@ -51,7 +51,11 @@ describe('resolveFeedCollection', () => {
51
51
 
52
52
  describe('createFeedConfig', () => {
53
53
  it('uses "My Site" as the title fallback with no collection label', () => {
54
- const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
54
+ const config = createFeedConfig({
55
+ site: {},
56
+ siteUrl: 'https://example.com',
57
+ collection: undefined,
58
+ })
55
59
  expect(config.title).toBe('My Site')
56
60
  })
57
61
 
@@ -65,7 +69,11 @@ describe('createFeedConfig', () => {
65
69
  })
66
70
 
67
71
  it('defaults description to an empty string', () => {
68
- const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
72
+ const config = createFeedConfig({
73
+ site: {},
74
+ siteUrl: 'https://example.com',
75
+ collection: undefined,
76
+ })
69
77
  expect(config.description).toBe('')
70
78
  })
71
79
 
@@ -80,7 +88,9 @@ describe('createFeedConfig', () => {
80
88
 
81
89
  it('builds an author object when the site has a named author', () => {
82
90
  const config = createFeedConfig({
83
- site: { author: { name: 'Kieran Mansfield', email: 'hi@example.com', link: 'https://example.com' } },
91
+ site: {
92
+ author: { name: 'Kieran Mansfield', email: 'hi@example.com', link: 'https://example.com' },
93
+ },
84
94
  siteUrl: 'https://example.com',
85
95
  collection: undefined,
86
96
  })
@@ -92,7 +102,11 @@ describe('createFeedConfig', () => {
92
102
  })
93
103
 
94
104
  it('omits author when the site author has no name', () => {
95
- const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
105
+ const config = createFeedConfig({
106
+ site: {},
107
+ siteUrl: 'https://example.com',
108
+ collection: undefined,
109
+ })
96
110
  expect(config.author).toBeUndefined()
97
111
  })
98
112
 
@@ -115,7 +129,11 @@ describe('createFeedConfig', () => {
115
129
  })
116
130
 
117
131
  it('defaults favicon to /favicon.ico', () => {
118
- const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
132
+ const config = createFeedConfig({
133
+ site: {},
134
+ siteUrl: 'https://example.com',
135
+ collection: undefined,
136
+ })
119
137
  expect(config.favicon).toBe('/favicon.ico')
120
138
  })
121
139
 
@@ -138,7 +156,11 @@ describe('createFeedConfig', () => {
138
156
  })
139
157
 
140
158
  it('omits copyright when there is no explicit copyright and no author name', () => {
141
- const config = createFeedConfig({ site: {}, siteUrl: 'https://example.com', collection: undefined })
159
+ const config = createFeedConfig({
160
+ site: {},
161
+ siteUrl: 'https://example.com',
162
+ collection: undefined,
163
+ })
142
164
  expect(config.copyright).toBeUndefined()
143
165
  })
144
166
  })
@@ -54,10 +54,7 @@ export function resolveFeedSiteConfig(appConfig: AppFeedConfig, requestUrl: URL)
54
54
  }
55
55
  }
56
56
 
57
- export function resolveFeedCollection(
58
- feedConfig: FeedLayerConfig,
59
- collection?: string
60
- ) {
57
+ export function resolveFeedCollection(feedConfig: FeedLayerConfig, collection?: string) {
61
58
  return collection ?? feedConfig?.defaultCollection ?? 'blog'
62
59
  }
63
60
 
@@ -1,12 +1,12 @@
1
1
  import type { H3Event } from 'h3'
2
2
 
3
- import type { FeedConfig, FeedItem } from './types'
4
3
  import {
5
4
  createFeedConfig,
6
5
  resolveFeedCollection,
7
6
  resolveFeedLimit,
8
7
  resolveFeedSiteConfig,
9
8
  } from './feed-config'
9
+ import type { FeedConfig, FeedItem } from './types'
10
10
 
11
11
  export async function buildFeed(
12
12
  event: H3Event,
@@ -1,12 +1,12 @@
1
1
  import { Feed } from 'feed'
2
2
 
3
- import type { FeedConfig, FeedItem } from '../types'
4
3
  import {
5
4
  applyFeedStylesheet,
6
5
  resolveFeedAuthorPayload,
7
6
  resolveFeedItemId,
8
7
  resolveFeedItemLink,
9
8
  } from '../feed-xml'
9
+ import type { FeedConfig, FeedItem } from '../types'
10
10
 
11
11
  export function toRSS(items: FeedItem[], config: FeedConfig): string {
12
12
  const author = resolveFeedAuthorPayload(config.author)
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import type { FeedConfig, FeedItem } from './types'
4
3
  import { toAtom } from './formats/atom'
5
4
  import { toJSONFeed } from './formats/json'
6
5
  import { toRSS } from './formats/rss'
6
+ import type { FeedConfig, FeedItem } from './types'
7
7
 
8
8
  const config: FeedConfig = {
9
9
  title: 'Nuxt Layers Feed',
@@ -1,5 +1,4 @@
1
- import type { FieldConfig } from '../types/fields'
2
- import type { FieldSize } from '../types/fields'
1
+ import type { FieldConfig, FieldSize } from '../types/fields'
3
2
 
4
3
  export function buildFormFieldProps(args: {
5
4
  name: string
@@ -23,7 +23,10 @@ function checkRateLimit(ip: string): boolean {
23
23
  export default defineEventHandler(async (event) => {
24
24
  const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
25
25
  if (!checkRateLimit(ip)) {
26
- throw createError({ statusCode: 429, statusMessage: 'Too many requests. Please try again later.' })
26
+ throw createError({
27
+ statusCode: 429,
28
+ statusMessage: 'Too many requests. Please try again later.',
29
+ })
27
30
  }
28
31
 
29
32
  const body = await readBody(event)
@@ -82,7 +82,7 @@
82
82
  min-width: 0;
83
83
  max-width: 100%;
84
84
  overflow-wrap: break-word;
85
- word-wrap: break-word;
85
+ overflow-wrap: break-word;
86
86
  }
87
87
 
88
88
  /* On mobile, allow sections to grow beyond 12 rows if needed */
@@ -102,52 +102,62 @@
102
102
  /* Line height utilities */
103
103
  .leading-rhythm-4 {
104
104
  --rhythm: 0.25rem;
105
+
105
106
  line-height: calc(var(--rhythm) * 4); /* 1rem */
106
107
  }
107
108
 
108
109
  .leading-rhythm-5 {
109
110
  --rhythm: 0.25rem;
111
+
110
112
  line-height: calc(var(--rhythm) * 5); /* 1.25rem */
111
113
  }
112
114
 
113
115
  .leading-rhythm-6 {
114
116
  --rhythm: 0.25rem;
117
+
115
118
  line-height: calc(var(--rhythm) * 6); /* 1.5rem */
116
119
  }
117
120
 
118
121
  .leading-rhythm-7 {
119
122
  --rhythm: 0.25rem;
123
+
120
124
  line-height: calc(var(--rhythm) * 7); /* 1.75rem */
121
125
  }
122
126
 
123
127
  .leading-rhythm-8 {
124
128
  --rhythm: 0.25rem;
129
+
125
130
  line-height: calc(var(--rhythm) * 8); /* 2rem */
126
131
  }
127
132
 
128
133
  /* Vertical spacing utilities */
129
134
  .space-rhythm-1 {
130
135
  --rhythm: 0.25rem;
136
+
131
137
  margin-block: var(--rhythm); /* 0.25rem */
132
138
  }
133
139
 
134
140
  .space-rhythm-2 {
135
141
  --rhythm: 0.25rem;
142
+
136
143
  margin-block: calc(var(--rhythm) * 2); /* 0.5rem */
137
144
  }
138
145
 
139
146
  .space-rhythm-4 {
140
147
  --rhythm: 0.25rem;
148
+
141
149
  margin-block: calc(var(--rhythm) * 4); /* 1rem */
142
150
  }
143
151
 
144
152
  .space-rhythm-6 {
145
153
  --rhythm: 0.25rem;
154
+
146
155
  margin-block: calc(var(--rhythm) * 6); /* 1.5rem */
147
156
  }
148
157
 
149
158
  .space-rhythm-8 {
150
159
  --rhythm: 0.25rem;
160
+
151
161
  margin-block: calc(var(--rhythm) * 8); /* 2rem */
152
162
  }
153
163
 
@@ -165,8 +175,7 @@
165
175
  & > h4,
166
176
  & > h5,
167
177
  & > h6 {
168
- margin-block-start: calc(var(--rhythm) * 8); /* 2rem before headings */
169
- margin-block-end: calc(var(--rhythm) * 4); /* 1rem after headings */
178
+ margin-block: calc(var(--rhythm) * 8) calc(var(--rhythm) * 4); /* 2rem before, 1rem after headings */
170
179
  line-height: calc(var(--rhythm) * 8);
171
180
  }
172
181
 
@@ -21,6 +21,7 @@
21
21
  */
22
22
  .fluid-grid {
23
23
  --fluid-col-min: 16rem;
24
+
24
25
  display: grid;
25
26
  grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
26
27
  gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
@@ -34,6 +35,7 @@
34
35
  */
35
36
  .fluid-grid-2 {
36
37
  --fluid-col-min: 14rem;
38
+
37
39
  display: grid;
38
40
  grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
39
41
  gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
@@ -45,6 +47,7 @@
45
47
 
46
48
  .fluid-grid-3 {
47
49
  --fluid-col-min: 14rem;
50
+
48
51
  display: grid;
49
52
  grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
50
53
  gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
@@ -56,6 +59,7 @@
56
59
 
57
60
  .fluid-grid-4 {
58
61
  --fluid-col-min: 12rem;
62
+
59
63
  display: grid;
60
64
  grid-template-columns: repeat(auto-fit, minmax(var(--fluid-col-min), 1fr));
61
65
  gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
@@ -73,6 +77,7 @@
73
77
  */
74
78
  .layout-container-content {
75
79
  box-sizing: border-box;
80
+ container-type: inline-size;
76
81
  margin-inline: auto;
77
82
  width: 100%;
78
83
  max-width: 65ch;
@@ -80,6 +85,7 @@
80
85
 
81
86
  .layout-container-wide {
82
87
  box-sizing: border-box;
88
+ container-type: inline-size;
83
89
  margin-inline: auto;
84
90
  width: 100%;
85
91
  max-width: 90rem;
@@ -87,13 +93,16 @@
87
93
 
88
94
  .layout-container-fluid {
89
95
  box-sizing: border-box;
96
+ container-type: inline-size;
90
97
  width: 100%;
91
98
  max-width: 100%;
92
99
  }
93
100
 
94
101
  .layout-container-full {
95
102
  box-sizing: border-box;
103
+ container-type: inline-size;
96
104
  margin-inline: calc(var(--grid-padding, clamp(1rem, 2.5vw, 2rem)) * -1);
105
+
97
106
  /* Escape parent padding to reach the viewport edge */
98
107
  width: 100vw;
99
108
  max-width: 100vw;
@@ -5,7 +5,7 @@
5
5
  * Two-column layout where the sidebar takes a clamped width and main content
6
6
  * fills the rest. Uses `minmax(min, max) 1fr` (or reversed for right sidebar).
7
7
  *
8
- * @prop {string} sidebarMin - Minimum sidebar width (default: '150px')
8
+ * @prop {string} sidebarMin - Minimum sidebar width (default: '9.375rem')
9
9
  * @prop {string} sidebarMax - Maximum sidebar width (default: '25%')
10
10
  * @prop {boolean} reverse - Put sidebar on the right (default: false)
11
11
  * @prop {boolean} fullHeight - Force 100svh on the section (default: false)
@@ -28,7 +28,7 @@
28
28
  }
29
29
 
30
30
  const {
31
- sidebarMin = '150px',
31
+ sidebarMin = '9.375rem',
32
32
  sidebarMax = '25%',
33
33
  reverse = false,
34
34
  fullHeight = false,
@@ -57,14 +57,21 @@
57
57
  <style scoped>
58
58
  .sidebar-inner {
59
59
  display: grid;
60
- grid-template-columns: minmax(var(--sidebar-min, 150px), var(--sidebar-max, 25%)) 1fr;
60
+ grid-template-columns: minmax(var(--sidebar-min, 9.375rem), var(--sidebar-max, 25%)) 1fr;
61
61
  gap: var(--grid-gap, clamp(0.75rem, 1.5vw, 1.5rem));
62
+ container-type: inline-size;
62
63
  width: 100%;
63
64
  height: 100%;
65
+
66
+ /* Collapse to a single column when the container itself is too narrow
67
+ for the sidebar split, regardless of viewport size. */
68
+ @container (width < 30rem) {
69
+ grid-template-columns: 1fr;
70
+ }
64
71
  }
65
72
 
66
73
  .sidebar-inner.sidebar-reverse {
67
- grid-template-columns: 1fr minmax(var(--sidebar-min, 150px), var(--sidebar-max, 25%));
74
+ grid-template-columns: 1fr minmax(var(--sidebar-min, 9.375rem), var(--sidebar-max, 25%));
68
75
  }
69
76
 
70
77
  .sidebar-inner.sidebar-reverse .sidebar-aside {
@@ -92,9 +92,7 @@ describe('resolveBleedStyles', () => {
92
92
  })
93
93
 
94
94
  it('handles "left" bleed direction with a numeric colSpan', () => {
95
- expect(
96
- resolveBleedStyles({ bleed: 'left', colSpan: 4, rowStart: 2, rowSpan: 1 })
97
- ).toEqual({
95
+ expect(resolveBleedStyles({ bleed: 'left', colSpan: 4, rowStart: 2, rowSpan: 1 })).toEqual({
98
96
  gridColumn: '1 / span 4',
99
97
  marginInlineStart: 'calc(-1 * var(--grid-padding))',
100
98
  gridRow: '2 / span 1',
@@ -102,9 +100,7 @@ describe('resolveBleedStyles', () => {
102
100
  })
103
101
 
104
102
  it('handles "left" bleed direction without a numeric colSpan', () => {
105
- expect(
106
- resolveBleedStyles({ bleed: 'left', colSpan: 'full', rowSpan: 1 })
107
- ).toEqual({
103
+ expect(resolveBleedStyles({ bleed: 'left', colSpan: 'full', rowSpan: 1 })).toEqual({
108
104
  gridColumn: '1 / -1',
109
105
  marginInlineStart: 'calc(-1 * var(--grid-padding))',
110
106
  gridRow: 'auto / span 1',
@@ -112,9 +108,7 @@ describe('resolveBleedStyles', () => {
112
108
  })
113
109
 
114
110
  it('handles "right" bleed direction', () => {
115
- expect(
116
- resolveBleedStyles({ bleed: 'right', colStart: 3, rowSpan: 2 })
117
- ).toEqual({
111
+ expect(resolveBleedStyles({ bleed: 'right', colStart: 3, rowSpan: 2 })).toEqual({
118
112
  gridColumn: '3 / -1',
119
113
  marginInlineEnd: 'calc(-1 * var(--grid-padding))',
120
114
  gridRow: 'auto / span 2',
@@ -149,13 +149,16 @@ export function buildGridPlacementStyle(input: GridPlacementInput): PlacementSty
149
149
  const rowStart = resolveDefaultPlacement(input.rowStart, undefined)
150
150
  const rowSpan = resolveDefaultPlacement(input.rowSpan, 1)
151
151
 
152
- Object.assign(style, resolveBleedStyles({
153
- bleed: input.bleed,
154
- colStart,
155
- colSpan,
156
- rowStart,
157
- rowSpan,
158
- }))
152
+ Object.assign(
153
+ style,
154
+ resolveBleedStyles({
155
+ bleed: input.bleed,
156
+ colStart,
157
+ colSpan,
158
+ rowStart,
159
+ rowSpan,
160
+ })
161
+ )
159
162
 
160
163
  if (!input.bleed) {
161
164
  if (colSpan === 'full') {
@@ -1,6 +1,5 @@
1
- import { Resend } from 'resend'
2
-
3
1
  import type { ContactEmailData } from '#layers/mailer/shared/contact'
2
+ import { Resend } from 'resend'
4
3
 
5
4
  type MailerTransport = {
6
5
  from: string
@@ -36,7 +35,9 @@ export async function sendContactEmail(data: ContactEmailData) {
36
35
  }
37
36
 
38
37
  const resend = new Resend(transport.apiKey)
39
- const { data: result, error } = await resend.emails.send(buildContactEmailPayload(data, transport))
38
+ const { data: result, error } = await resend.emails.send(
39
+ buildContactEmailPayload(data, transport)
40
+ )
40
41
 
41
42
  if (error) return { success: false as const, error }
42
43
  return { success: true as const, messageId: result?.id ?? '' }
@@ -1,4 +1,4 @@
1
- import { createHooks } from 'hookable'
2
1
  import type { MailerLayerHooks } from '#layers/mailer/shared/contact'
2
+ import { createHooks } from 'hookable'
3
3
 
4
4
  export const mailerLayerHooks = createHooks<MailerLayerHooks>()
@@ -1,11 +1,15 @@
1
1
  import type { MetadataProviderId, MetadataRecord } from '#layers/metadata/shared/types'
2
2
 
3
- export function useMetadataItem(provider: MetadataProviderId, providerId: string, resourceType?: string) {
3
+ export function useMetadataItem(
4
+ provider: MetadataProviderId,
5
+ providerId: string,
6
+ resourceType?: string
7
+ ) {
4
8
  return useAsyncData(
5
9
  `metadata-item:${provider}:${providerId}`,
6
10
  () =>
7
11
  $fetch('/api/metadata/lookup', {
8
12
  query: { provider, providerId, resourceType },
9
- }) as Promise<MetadataRecord>,
13
+ }) as Promise<MetadataRecord>
10
14
  )
11
15
  }
@@ -1,4 +1,8 @@
1
- import type { MetadataMediaType, MetadataProviderId, MetadataSearchResult } from '#layers/metadata/shared/types'
1
+ import type {
2
+ MetadataMediaType,
3
+ MetadataProviderId,
4
+ MetadataSearchResult,
5
+ } from '#layers/metadata/shared/types'
2
6
 
3
7
  type UseMetadataSearchOptions = {
4
8
  mediaType?: MetadataMediaType
@@ -20,6 +24,6 @@ export function useMetadataSearch(query: MaybeRef<string>, options: UseMetadataS
20
24
  limit: options.limit,
21
25
  },
22
26
  }) as Promise<MetadataSearchResult[]>,
23
- { watch: [q], immediate: false },
27
+ { watch: [q], immediate: false }
24
28
  )
25
29
  }
@@ -1,4 +1,4 @@
1
- import type { ComicVineApiResponse } from './types'
1
+ import type { ComicVineApiResponse, ComicVineIssue, ComicVineVolume } from './types'
2
2
 
3
3
  const FIELD_LIST_ISSUE =
4
4
  'id,name,issue_number,description,deck,cover_date,store_date,site_detail_url,image,volume,person_credits,publisher'
@@ -29,7 +29,7 @@ async function comicVineFetch<T>(
29
29
  }
30
30
 
31
31
  export async function searchComicVineIssues(query: string, limit = 10) {
32
- return comicVineFetch<Array<import('./types').ComicVineIssue>>('/search/', {
32
+ return comicVineFetch<ComicVineIssue[]>('/search/', {
33
33
  query,
34
34
  resources: 'issue',
35
35
  limit: String(limit),
@@ -38,7 +38,7 @@ export async function searchComicVineIssues(query: string, limit = 10) {
38
38
  }
39
39
 
40
40
  export async function searchComicVineVolumes(query: string, limit = 10) {
41
- return comicVineFetch<Array<import('./types').ComicVineVolume>>('/search/', {
41
+ return comicVineFetch<ComicVineVolume[]>('/search/', {
42
42
  query,
43
43
  resources: 'volume',
44
44
  limit: String(limit),
@@ -47,13 +47,13 @@ export async function searchComicVineVolumes(query: string, limit = 10) {
47
47
  }
48
48
 
49
49
  export async function fetchComicVineIssue(id: string) {
50
- return comicVineFetch<import('./types').ComicVineIssue>(`/issue/4000-${id}/`, {
50
+ return comicVineFetch<ComicVineIssue>(`/issue/4000-${id}/`, {
51
51
  field_list: FIELD_LIST_ISSUE,
52
52
  })
53
53
  }
54
54
 
55
55
  export async function fetchComicVineVolume(id: string) {
56
- return comicVineFetch<import('./types').ComicVineVolume>(`/volume/4050-${id}/`, {
56
+ return comicVineFetch<ComicVineVolume>(`/volume/4050-${id}/`, {
57
57
  field_list: FIELD_LIST_VOLUME,
58
58
  })
59
59
  }
@@ -1,5 +1,7 @@
1
1
  import type { MetadataProvider } from '#layers/metadata/shared/types'
2
2
 
3
+ import type { ComicVineIssue, ComicVineVolume } from './types'
4
+
3
5
  // Matches CJK, Hangul, Arabic, Hebrew, Thai, Devanagari, Cyrillic, etc.
4
6
  const NON_LATIN_RE = /[Ͱ-ϿЀ-ӿ؀-ۿऀ-ॿ -鿿가-힯豈-﫿]/
5
7
 
@@ -44,13 +46,19 @@ export const comicVineProvider: MetadataProvider = {
44
46
  async lookup({ providerId, resourceType = 'issue' }) {
45
47
  if (resourceType === 'volume') {
46
48
  const res = await fetchComicVineVolume(providerId)
47
- return normaliseComicVineVolume(res.results as import('./types').ComicVineVolume)
49
+ return normaliseComicVineVolume(res.results as ComicVineVolume)
48
50
  }
49
51
  const res = await fetchComicVineIssue(providerId)
50
- return normaliseComicVineIssue(res.results as import('./types').ComicVineIssue)
52
+ return normaliseComicVineIssue(res.results as ComicVineIssue)
51
53
  },
52
54
 
53
55
  async sync({ providerId, resourceType = 'issue' }) {
54
- return (await comicVineProvider.lookup!({ provider: 'comicvine', providerId, resourceType }))!
56
+ const record = await comicVineProvider.lookup({
57
+ provider: 'comicvine',
58
+ providerId,
59
+ resourceType,
60
+ })
61
+ if (!record) throw new MetadataProviderError('comicvine', `No record found for ${providerId}`)
62
+ return record
55
63
  },
56
64
  }
@@ -1,8 +1,10 @@
1
+ import type { MetadataMediaType } from '#layers/metadata/shared/types'
2
+
1
3
  import type { GoogleBooksSearchResponse, GoogleBooksVolume } from './types'
2
4
 
3
5
  const BASE = 'https://www.googleapis.com/books/v1'
4
6
 
5
- export function googleBooksMediaType(categories?: string[]): import('#layers/metadata/shared/types').MetadataMediaType {
7
+ export function googleBooksMediaType(categories?: string[]): MetadataMediaType {
6
8
  const joined = (categories ?? []).join(' ').toLowerCase()
7
9
  if (joined.includes('manga')) return 'manga'
8
10
  if (joined.includes('comic') || joined.includes('graphic novel')) return 'graphic-novel'
@@ -10,11 +12,12 @@ export function googleBooksMediaType(categories?: string[]): import('#layers/met
10
12
  }
11
13
 
12
14
  export function googleBooksCoverUrl(url: string, width = 800): string {
13
- return url
14
- .replace('http://', 'https://')
15
- .replace(/&zoom=\d+/, '&zoom=3')
16
- .replace('&edge=curl', '')
17
- + `&fife=w${width}`
15
+ return (
16
+ url
17
+ .replace('http://', 'https://')
18
+ .replace(/&zoom=\d+/, '&zoom=3')
19
+ .replace('&edge=curl', '') + `&fife=w${width}`
20
+ )
18
21
  }
19
22
 
20
23
  function getApiKey(): string | undefined {