kmcom-nuxt-layers 6.2.1 → 6.4.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 (77) hide show
  1. package/docs/MIGRATION-v6.3.0.md +234 -0
  2. package/docs/MIGRATION-v6.4.0.md +215 -0
  3. package/docs/MIGRATION.md +3 -0
  4. package/docs/adr/0003-modular-step-system-replaces-fluid-clamps.md +16 -0
  5. package/docs/component-levels.md +117 -0
  6. package/docs/layout-shift-causes.md +18 -0
  7. package/layers/content/app/components/Blog/ItemPage.vue +2 -7
  8. package/layers/content/app/components/Blog/List.vue +9 -2
  9. package/layers/content/app/components/Blog/Page.vue +9 -19
  10. package/layers/content/app/components/Gallery/Detail.vue +1 -0
  11. package/layers/content/app/components/Gallery/Grid.vue +9 -2
  12. package/layers/content/app/components/Gallery/ImageDetail.vue +0 -6
  13. package/layers/content/app/components/Gallery/ImagePage.vue +10 -6
  14. package/layers/content/app/components/Gallery/Page.vue +9 -19
  15. package/layers/content/app/components/Gallery/SlugPage.vue +2 -7
  16. package/layers/content/app/components/NuxtContent/Detail.vue +20 -11
  17. package/layers/content/app/components/NuxtContent/IndexTemplate.vue +21 -0
  18. package/layers/content/app/components/NuxtContent/List.vue +6 -1
  19. package/layers/content/app/components/Portfolio/ColorPalette.vue +2 -2
  20. package/layers/content/app/components/Portfolio/ItemPage.vue +2 -6
  21. package/layers/content/app/components/Portfolio/List.vue +19 -8
  22. package/layers/content/app/components/Portfolio/Page.vue +9 -19
  23. package/layers/content/app/composables/useContentItemSeo.ts +19 -0
  24. package/layers/content/app/composables/useContentSection.ts +9 -0
  25. package/layers/content/app/utils/galleryImageMeta.test.ts +30 -0
  26. package/layers/content/app/utils/galleryImageMeta.ts +11 -0
  27. package/layers/content/nuxt.config.ts +1 -1
  28. package/layers/core/app/app.config.ts +2 -2
  29. package/layers/core/app/app.vue +2 -1
  30. package/layers/core/app/assets/css/core.css +5 -11
  31. package/layers/core/app/assets/css/tokens/z-index.css +16 -0
  32. package/layers/core/app/components/DiagnosticsPage.vue +41 -41
  33. package/layers/core/app/components/LoadingScreen.vue +2 -0
  34. package/layers/core/app/utils/loading.test.ts +2 -2
  35. package/layers/core/app/utils/loading.ts +1 -1
  36. package/layers/delivery/feeds/app/components/Feeds/Index.vue +36 -75
  37. package/layers/delivery/feeds/app/components/Feeds/RouteCard.vue +6 -8
  38. package/layers/design-system/theming/nuxt.config.ts +1 -3
  39. package/layers/design-system/typography/app/assets/css/typography.css +41 -5
  40. package/layers/design-system/visual/app/components/Base/Modal.vue +34 -86
  41. package/layers/design-system/visual/app/components/HoverDimGroup.test.ts +38 -0
  42. package/layers/design-system/visual/app/components/HoverDimGroup.vue +54 -0
  43. package/layers/design-system/visual/app/components/Progress/Bar.vue +9 -29
  44. package/layers/motion/animations/app/components/Motion/Cursor.vue +1 -1
  45. package/layers/motion/animations/app/components/Motion/Marquee.vue +5 -0
  46. package/layers/motion/animations/app/components/Motion/MarqueeText.vue +2 -1
  47. package/layers/motion/animations/app/components/Motion/Staggered.test.ts +40 -0
  48. package/layers/motion/animations/app/components/Motion/Staggered.vue +37 -42
  49. package/layers/motion/animations/app/components/Motion/TextReveal.vue +8 -1
  50. package/layers/motion/animations/app/components/Motion/VelocityEffect.vue +6 -0
  51. package/layers/motion/animations/app/composables/useCountUp.ts +6 -0
  52. package/layers/motion/animations/app/composables/useCursorFollower.ts +4 -1
  53. package/layers/motion/animations/app/utils/pointerMotion.ts +7 -2
  54. package/layers/motion/scroll/app/components/Motion/HorizontalScroll.vue +9 -0
  55. package/layers/motion/scroll/app/components/Motion/Parallax.vue +13 -12
  56. package/layers/motion/scroll/app/components/Motion/PinnedSection.vue +16 -9
  57. package/layers/motion/scroll/app/composables/useReducedMotion.test.ts +23 -0
  58. package/layers/motion/scroll/app/composables/useReducedMotion.ts +14 -0
  59. package/layers/motion/scroll/app/utils/parallax.test.ts +24 -0
  60. package/layers/motion/scroll/app/utils/parallax.ts +16 -0
  61. package/layers/motion/scroll/nuxt.config.ts +1 -1
  62. package/layers/structure/layout/app/components/Layout/Grid/Debug.vue +1 -1
  63. package/layers/structure/layout/app/components/Layout/Page/index.vue +9 -10
  64. package/layers/structure/navigation/app/app.config.ts +37 -14
  65. package/layers/structure/navigation/app/components/Links/Group.vue +12 -38
  66. package/layers/structure/navigation/app/components/Links/Named.vue +9 -0
  67. package/layers/structure/navigation/app/components/Mast/Footer.vue +1 -1
  68. package/layers/structure/navigation/app/components/Mast/Nav.vue +14 -11
  69. package/layers/structure/navigation/app/components/Mast/NavModal.vue +95 -45
  70. package/layers/structure/navigation/app/components/Site/Logo.vue +35 -0
  71. package/layers/structure/navigation/app/components/Site/Subtitle.vue +10 -0
  72. package/layers/structure/navigation/app/components/Site/Title.vue +8 -0
  73. package/layers/structure/navigation/app/composables/mastNav.ts +38 -13
  74. package/layers/structure/navigation/app/types/nav.ts +34 -0
  75. package/layers/structure/navigation/nuxt.config.ts +7 -1
  76. package/package.json +1 -1
  77. /package/layers/{design-system/theming/app/assets/css/tokens.css → core/app/assets/css/tokens/elevation-motion.css} +0 -0
@@ -1,11 +1,18 @@
1
1
  <script setup lang="ts">
2
+ import type { BlogCollectionItem } from '@nuxt/content'
3
+
2
4
  import type { BlogQueryOptions } from '../../types/content'
3
5
 
4
- const { options = {} } = defineProps<{
6
+ const { items = undefined, options = {} } = defineProps<{
7
+ items?: BlogCollectionItem[]
5
8
  options?: BlogQueryOptions
6
9
  }>()
7
10
 
8
- const { data: posts, status } = await useBlogPosts(options)
11
+ // Fetch only when the parent didn't pass items (e.g. a related-items list it already has).
12
+ // ponytail: decided once at setup — toggling `items` after mount won't start or stop the fetch
13
+ const fetched = items ? undefined : await useBlogPosts(options)
14
+ const posts = computed(() => items ?? fetched?.data.value)
15
+ const status = computed(() => (items ? 'success' : (fetched?.status.value ?? 'idle')))
9
16
  </script>
10
17
 
11
18
  <template>
@@ -1,25 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
2
+ const { title = 'Blog', description = 'Articles and posts' } = defineProps<{
3
+ title?: string
4
+ description?: string
5
+ }>()
3
6
 
4
- const appConfig = useAppConfig()
5
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
6
- if (contentLayer?.sections?.blog === false) {
7
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
8
- }
9
-
10
- useSeoMeta({
11
- title: 'Blog',
12
- description: 'Articles and posts',
13
- })
7
+ useContentSection('blog')
8
+ useSeoMeta({ title: () => title, description: () => description })
14
9
  </script>
15
10
 
16
11
  <template>
17
- <LayoutSection>
18
- <LayoutGridItem>
19
- <UPageHeader title="Blog" description="Articles and posts" />
20
- <UPageBody>
21
- <BlogList />
22
- </UPageBody>
23
- </LayoutGridItem>
24
- </LayoutSection>
12
+ <NuxtContentIndexTemplate :title :description>
13
+ <BlogList />
14
+ </NuxtContentIndexTemplate>
25
15
  </template>
@@ -66,6 +66,7 @@
66
66
  </div>
67
67
  </NuxtLink>
68
68
  <button
69
+ :aria-label="`View ${img.alt || 'image'} larger`"
69
70
  class="absolute top-2 right-2 z-10 cursor-pointer rounded-full bg-black/50 p-1.5 text-white opacity-0 transition-opacity duration-300 group-hover:opacity-100 hover:bg-black/70"
70
71
  @click.stop="() => openLightbox(asGallery(item).images!, idx)"
71
72
  >
@@ -1,11 +1,18 @@
1
1
  <script setup lang="ts">
2
+ import type { GalleryCollectionItem } from '@nuxt/content'
3
+
2
4
  import type { GalleryQueryOptions } from '../../types/content'
3
5
 
4
- const { options = {} } = defineProps<{
6
+ const { items: passed = undefined, options = {} } = defineProps<{
7
+ items?: GalleryCollectionItem[]
5
8
  options?: GalleryQueryOptions
6
9
  }>()
7
10
 
8
- const { data: items, status } = await useGalleryItems(options)
11
+ // Fetch only when the parent didn't pass items (e.g. a related-items list it already has).
12
+ // ponytail: decided once at setup — toggling `items` after mount won't start or stop the fetch
13
+ const fetched = passed ? undefined : await useGalleryItems(options)
14
+ const items = computed(() => passed ?? fetched?.data.value)
15
+ const status = computed(() => (passed ? 'success' : (fetched?.status.value ?? 'idle')))
9
16
  </script>
10
17
 
11
18
  <template>
@@ -13,12 +13,6 @@
13
13
  const totalImages = computed(() => item.value?.images?.length ?? 0)
14
14
  const hasPrevious = computed(() => index > 0)
15
15
  const hasNext = computed(() => index < totalImages.value - 1)
16
-
17
- useSeoMeta({
18
- title: () => image.value?.title || image.value?.alt || `Image ${index + 1}`,
19
- description: () => image.value?.caption || item.value?.title,
20
- ogImage: () => image.value?.src,
21
- })
22
16
  </script>
23
17
 
24
18
  <template>
@@ -1,16 +1,20 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
2
+ import type { GalleryCollectionItem } from '@nuxt/content'
3
3
 
4
4
  const { slug, index } = defineProps<{
5
5
  slug: string
6
6
  index: number
7
7
  }>()
8
8
 
9
- const appConfig = useAppConfig()
10
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
11
- if (contentLayer?.sections?.gallery === false) {
12
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
13
- }
9
+ useContentSection('gallery')
10
+
11
+ const { data } = await useCollectionItem('gallery', slug)
12
+ const meta = computed(() => galleryImageMeta(data.value as GalleryCollectionItem | null, index))
13
+ useSeoMeta({
14
+ title: () => meta.value.title,
15
+ description: () => meta.value.description,
16
+ ogImage: () => meta.value.ogImage,
17
+ })
14
18
  </script>
15
19
 
16
20
  <template>
@@ -1,25 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
2
+ const { title = 'Gallery', description = 'Photo collections and visual work' } = defineProps<{
3
+ title?: string
4
+ description?: string
5
+ }>()
3
6
 
4
- const appConfig = useAppConfig()
5
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
6
- if (contentLayer?.sections?.gallery === false) {
7
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
8
- }
9
-
10
- useSeoMeta({
11
- title: 'Gallery',
12
- description: 'Photo collections and visual work',
13
- })
7
+ useContentSection('gallery')
8
+ useSeoMeta({ title: () => title, description: () => description })
14
9
  </script>
15
10
 
16
11
  <template>
17
- <LayoutSection>
18
- <LayoutGridItem>
19
- <UPageHeader title="Gallery" description="Photo collections and visual work" />
20
- <UPageBody>
21
- <GalleryGrid />
22
- </UPageBody>
23
- </LayoutGridItem>
24
- </LayoutSection>
12
+ <NuxtContentIndexTemplate :title :description>
13
+ <GalleryGrid />
14
+ </NuxtContentIndexTemplate>
25
15
  </template>
@@ -1,15 +1,10 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
3
-
4
2
  const { slug } = defineProps<{
5
3
  slug: string
6
4
  }>()
7
5
 
8
- const appConfig = useAppConfig()
9
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
10
- if (contentLayer?.sections?.gallery === false) {
11
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
12
- }
6
+ useContentSection('gallery')
7
+ await useContentItemSeo('gallery', slug)
13
8
  </script>
14
9
 
15
10
  <template>
@@ -20,23 +20,32 @@
20
20
  const surroundLinks = computed<ContentSurroundLink[] | undefined>(
21
21
  () => surround.value ?? undefined
22
22
  )
23
- const itemImage = computed(() => (item.value as { image?: string } | null)?.image)
24
23
 
25
- useSeoMeta({
26
- title: item.value?.title,
27
- description: item.value?.description,
28
- ogImage: itemImage,
24
+ // Optional props only bound when present (exactOptionalPropertyTypes rejects `undefined`)
25
+ const headerProps = computed(() =>
26
+ item.value?.description ? { description: item.value.description } : {}
27
+ )
28
+ const surroundProps = computed(() =>
29
+ surroundLinks.value?.length ? { surround: surroundLinks.value } : {}
30
+ )
31
+ const tocProps = computed(() => {
32
+ const links = item.value?.body?.toc?.links
33
+ return links ? { links } : {}
29
34
  })
30
35
  </script>
31
36
 
32
37
  <template>
33
38
  <div>
34
39
  <USkeleton v-if="status === 'pending'" class="h-96 w-full" />
40
+ <UAlert
41
+ v-else-if="status === 'error'"
42
+ color="error"
43
+ variant="subtle"
44
+ icon="lucide:circle-alert"
45
+ title="This page couldn't be loaded. Try again shortly."
46
+ />
35
47
  <UPage v-else-if="item">
36
- <UPageHeader
37
- :title="item.title"
38
- v-bind="item.description ? { description: item.description } : {}"
39
- >
48
+ <UPageHeader :title="item.title" v-bind="headerProps">
40
49
  <template v-if="$slots.headline" #headline>
41
50
  <slot name="headline" :item />
42
51
  </template>
@@ -51,11 +60,11 @@
51
60
  <slot name="after-content" :item />
52
61
 
53
62
  <USeparator class="my-8" />
54
- <NuxtContentSurround v-bind="surroundLinks?.length ? { surround: surroundLinks } : {}" />
63
+ <NuxtContentSurround v-bind="surroundProps" />
55
64
  </UPageBody>
56
65
 
57
66
  <template v-if="!hideToc" #right>
58
- <NuxtContentToc v-bind="item.body?.toc?.links ? { links: item.body.toc.links } : {}" />
67
+ <NuxtContentToc v-bind="tocProps" />
59
68
  </template>
60
69
  </UPage>
61
70
  <div v-else class="text-muted py-12 text-center">{{ notFoundMessage }}</div>
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Content index skeleton — a page header over a list. Props and slots only: fetching, meta
4
+ * and the section guard belong to the *Page component that fills it.
5
+ */
6
+ const { title, description = undefined } = defineProps<{
7
+ title: string
8
+ description?: string
9
+ }>()
10
+ </script>
11
+
12
+ <template>
13
+ <LayoutSection>
14
+ <LayoutGridItem>
15
+ <UPageHeader :title v-bind="description ? { description } : {}" />
16
+ <UPageBody>
17
+ <slot />
18
+ </UPageBody>
19
+ </LayoutGridItem>
20
+ </LayoutSection>
21
+ </template>
@@ -3,16 +3,21 @@
3
3
  status,
4
4
  hasItems = true,
5
5
  emptyMessage = 'No items found',
6
+ errorMessage = "These items couldn't be loaded. Try again shortly.",
6
7
  } = defineProps<{
7
- status: string
8
+ status: 'idle' | 'pending' | 'success' | 'error'
8
9
  hasItems?: boolean
9
10
  emptyMessage?: string
11
+ errorMessage?: string
10
12
  }>()
11
13
  </script>
12
14
 
13
15
  <template>
14
16
  <div>
15
17
  <USkeleton v-if="status === 'pending'" class="h-64 w-full" />
18
+ <slot v-else-if="status === 'error'" name="error">
19
+ <UAlert color="error" variant="subtle" icon="lucide:circle-alert" :title="errorMessage" />
20
+ </slot>
16
21
  <template v-else-if="hasItems">
17
22
  <slot />
18
23
  </template>
@@ -17,7 +17,7 @@
17
17
  <template>
18
18
  <div v-if="colors.length" class="space-y-6">
19
19
  <h3 class="text-highlighted text-2xl font-semibold tracking-tight">Palette</h3>
20
- <LinksGroup class="palette-grid gap-3">
20
+ <HoverDimGroup class="palette-grid gap-3">
21
21
  <div v-for="color in colors" :key="color.value">
22
22
  <div
23
23
  class="border-default/20 flex aspect-2/3 cursor-default flex-col justify-between rounded-lg border p-4 shadow-lg"
@@ -30,7 +30,7 @@
30
30
  <p class="font-mono text-xs uppercase opacity-50">{{ color.value }}</p>
31
31
  </div>
32
32
  </div>
33
- </LinksGroup>
33
+ </HoverDimGroup>
34
34
  </div>
35
35
  </template>
36
36
 
@@ -1,5 +1,4 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
3
2
  import type { PageCollections } from '@nuxt/content'
4
3
 
5
4
  const { slug, collection = 'portfolio' } = defineProps<{
@@ -7,11 +6,8 @@
7
6
  collection?: keyof PageCollections
8
7
  }>()
9
8
 
10
- const appConfig = useAppConfig()
11
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
12
- if (contentLayer?.sections?.portfolio === false) {
13
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
14
- }
9
+ useContentSection('portfolio')
10
+ await useContentItemSeo(collection, slug)
15
11
  </script>
16
12
 
17
13
  <template>
@@ -3,19 +3,30 @@
3
3
 
4
4
  import type { PortfolioQueryOptions } from '../../types/content'
5
5
 
6
- const { options = {}, collection = 'portfolio' } = defineProps<{
6
+ const {
7
+ items: passed = undefined,
8
+ options = {},
9
+ collection = 'portfolio',
10
+ } = defineProps<{
11
+ items?: Collections[keyof Collections][]
7
12
  options?: PortfolioQueryOptions
8
13
  collection?: keyof Collections
9
14
  }>()
10
15
 
11
16
  const { featured, tags, limit } = options
12
- const { data: items, status } = await useCollectionItems(collection, {
13
- tags,
14
- limit,
15
- sortKey: 'year',
16
- sortDirection: 'desc',
17
- filter: featured !== undefined ? (item) => fieldFeatured(item) === featured : undefined,
18
- })
17
+ // Fetch only when the parent didn't pass items (e.g. a related-items list it already has).
18
+ // ponytail: decided once at setup — toggling `items` after mount won't start or stop the fetch
19
+ const fetched = passed
20
+ ? undefined
21
+ : await useCollectionItems(collection, {
22
+ tags,
23
+ limit,
24
+ sortKey: 'year',
25
+ sortDirection: 'desc',
26
+ filter: featured !== undefined ? (item) => fieldFeatured(item) === featured : undefined,
27
+ })
28
+ const items = computed(() => passed ?? fetched?.data.value)
29
+ const status = computed(() => (passed ? 'success' : (fetched?.status.value ?? 'idle')))
19
30
 
20
31
  function fieldFeatured(item: unknown): boolean {
21
32
  return typeof item === 'object' && item !== null && 'featured' in item
@@ -1,25 +1,15 @@
1
1
  <script setup lang="ts">
2
- import type { ContentLayerConfig } from '#layers/content/app/types/content'
2
+ const { title = 'Portfolio', description = 'Featured projects and work' } = defineProps<{
3
+ title?: string
4
+ description?: string
5
+ }>()
3
6
 
4
- const appConfig = useAppConfig()
5
- const contentLayer = appConfig.contentLayer as ContentLayerConfig | undefined
6
- if (contentLayer?.sections?.portfolio === false) {
7
- throw createError({ statusCode: 404, statusMessage: 'Not Found' })
8
- }
9
-
10
- useSeoMeta({
11
- title: 'Portfolio',
12
- description: 'Featured projects and work',
13
- })
7
+ useContentSection('portfolio')
8
+ useSeoMeta({ title: () => title, description: () => description })
14
9
  </script>
15
10
 
16
11
  <template>
17
- <LayoutSection>
18
- <LayoutGridItem>
19
- <UPageHeader title="Portfolio" description="Featured projects and work" />
20
- <UPageBody>
21
- <PortfolioList />
22
- </UPageBody>
23
- </LayoutGridItem>
24
- </LayoutSection>
12
+ <NuxtContentIndexTemplate :title :description>
13
+ <PortfolioList />
14
+ </NuxtContentIndexTemplate>
25
15
  </template>
@@ -0,0 +1,19 @@
1
+ import type { Collections } from '@nuxt/content'
2
+
3
+ /**
4
+ * Page meta for a content item. Called by the *Page components rather than the detail
5
+ * organisms, so an organism never sets page meta. Shares useCollectionItem's cache key with
6
+ * the organism that renders the item.
7
+ *
8
+ * Meta is registered before anything is awaited — Nuxt's instance is gone after an `await`
9
+ * inside a plain composable. Returns the async data so the caller can `await` it.
10
+ */
11
+ export function useContentItemSeo(collection: keyof Collections, slug: string) {
12
+ const result = useCollectionItem(collection, slug)
13
+ useSeoMeta({
14
+ title: () => result.data.value?.title,
15
+ description: () => result.data.value?.description,
16
+ ogImage: () => (result.data.value as { image?: string } | null)?.image,
17
+ })
18
+ return result
19
+ }
@@ -0,0 +1,9 @@
1
+ import type { ContentLayerConfig } from '../types/content'
2
+
3
+ /** 404s the current route when `contentLayer.sections.<section>` is switched off in app.config. */
4
+ export function useContentSection(section: keyof NonNullable<ContentLayerConfig['sections']>) {
5
+ const contentLayer = useAppConfig().contentLayer as ContentLayerConfig | undefined
6
+ if (contentLayer?.sections?.[section] === false) {
7
+ throw createError({ statusCode: 404, statusMessage: 'Not Found' })
8
+ }
9
+ }
@@ -0,0 +1,30 @@
1
+ import type { GalleryCollectionItem } from '@nuxt/content'
2
+ import { describe, expect, it } from 'vite-plus/test'
3
+
4
+ import { galleryImageMeta } from './galleryImageMeta'
5
+
6
+ const gallery = (images: Array<Record<string, string>>) =>
7
+ ({ title: 'Autumn', images }) as unknown as GalleryCollectionItem
8
+
9
+ describe('galleryImageMeta', () => {
10
+ it("prefers the image's own title and caption", () => {
11
+ const item = gallery([{ src: '/a.jpg', alt: 'Leaf', title: 'Red leaf', caption: 'Oct' }])
12
+ expect(galleryImageMeta(item, 0)).toEqual({
13
+ title: 'Red leaf',
14
+ description: 'Oct',
15
+ ogImage: '/a.jpg',
16
+ })
17
+ })
18
+
19
+ it('falls back to alt text, then the gallery title', () => {
20
+ expect(galleryImageMeta(gallery([{ src: '/a.jpg', alt: 'Leaf' }]), 0)).toMatchObject({
21
+ title: 'Leaf',
22
+ description: 'Autumn',
23
+ })
24
+ })
25
+
26
+ it('names a missing image by its position', () => {
27
+ expect(galleryImageMeta(gallery([]), 2).title).toBe('Image 3')
28
+ expect(galleryImageMeta(null, 0).title).toBe('Image 1')
29
+ })
30
+ })
@@ -0,0 +1,11 @@
1
+ import type { GalleryCollectionItem } from '@nuxt/content'
2
+
3
+ /** Page meta for one image of a gallery: its own title/caption, falling back to the gallery's. */
4
+ export function galleryImageMeta(item: GalleryCollectionItem | null | undefined, index: number) {
5
+ const image = item?.images?.[index]
6
+ return {
7
+ title: image?.title || image?.alt || `Image ${index + 1}`,
8
+ description: image?.caption || item?.title,
9
+ ogImage: image?.src,
10
+ }
11
+ }
@@ -13,7 +13,7 @@ export default defineNuxtConfig({
13
13
  name: 'content',
14
14
  },
15
15
 
16
- extends: ['../core'],
16
+ extends: ['../core', '../structure/layout', '../design-system/visual'],
17
17
 
18
18
  alias: {
19
19
  '#layers/content': import.meta.dirname,
@@ -31,8 +31,8 @@ export default defineAppConfig({
31
31
 
32
32
  // Loading screen configuration (initial app load only)
33
33
  loading: {
34
- // Master toggle - set to false to disable completely
35
- enabled: true,
34
+ // Opt in: app.vue renders LoadingScreen for every app, so it stays off unless enabled
35
+ enabled: false,
36
36
  // Minimum time (ms, from navigation start) the count runs before reaching 100 (prevents flash)
37
37
  minDuration: 1500,
38
38
  // Longest wait (ms, after mount) for fonts and images before finishing anyway
@@ -1,9 +1,10 @@
1
1
  <template>
2
+ <!-- UApp already renders UOverlayProvider; a second one rendered every overlay twice. -->
2
3
  <UApp>
4
+ <LoadingScreen />
3
5
  <NuxtLoadingIndicator />
4
6
  <NuxtLayout>
5
7
  <NuxtPage />
6
8
  </NuxtLayout>
7
- <UOverlayProvider />
8
9
  </UApp>
9
10
  </template>
@@ -33,6 +33,7 @@
33
33
  @import '#layers/core/app/assets/css/tokens/breakpoints.css';
34
34
  @import '#layers/core/app/assets/css/tokens/spacing-fluid.css';
35
35
  @import '#layers/core/app/assets/css/tokens/container.css';
36
+ @import '#layers/core/app/assets/css/tokens/z-index.css';
36
37
 
37
38
  /* Relative path, not the '#layers/typography' alias — that alias only exists
38
39
  when the typography layer is in a consumer's `extends`. This file is a CSS
@@ -41,14 +42,7 @@
41
42
  only extend core, since it lives in the same single published package. */
42
43
  @import '../../../../design-system/typography/app/assets/css/typography.css';
43
44
 
44
- /* Same relative-path requirement as typography.css above. tokens.css defines
45
- an `@theme static` block (radius/shadow/duration) — per the CSS
46
- single-build-root rule, that only merges into Tailwind's theme if Tailwind's
47
- own PostCSS pipeline actually processes the file via @import from this one
48
- root. It previously was NOT imported here — only registered as a standalone
49
- stylesheet via theming/nuxt.config.ts's `css:` array — so its @theme block
50
- was silently inert: the browser just ignores the unrecognized at-rule, and
51
- every rounded-, shadow-, duration- utility fell back to Tailwind's stock
52
- defaults instead of this file's values. Confirmed via compiled dev output:
53
- the custom --shadow-sm value never appeared; Tailwind's built-in default did. */
54
- @import '../../../../design-system/theming/app/assets/css/tokens.css';
45
+ /* Shadow/duration tokens. Lived in theming until the 2026-10 atomic audit — moved here because
46
+ sub-atomic tokens belong to the foundation, and an @theme block only merges into Tailwind's
47
+ theme when it's in this one build root's @import chain anyway. */
48
+ @import '#layers/core/app/assets/css/tokens/elevation-motion.css';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * UI stacking scale, for layers that can't reach layout's useZIndex() (it reads
3
+ * layoutLayer.ui.grid.layers from app.config). The shared steps mirror that config —
4
+ * tests/vitest/z-index-scale.test.ts fails if they drift. Use as `z-(--z-modal)`, never a raw z-9999.
5
+ */
6
+ :root {
7
+ --z-header: 100;
8
+ --z-dropdown: 200;
9
+ --z-overlay: 300;
10
+ --z-modal: 400;
11
+ --z-toast: 500;
12
+
13
+ /* Above everything interactive — not in layout's scale, only these consumers need them */
14
+ --z-cursor: 600;
15
+ --z-debug: 700;
16
+ }