kmcom-nuxt-layers 5.0.7 → 6.0.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 (47) hide show
  1. package/docs/adr/0001-grid-replaces-layout-swiss-grid.md +30 -0
  2. package/docs/adr/0002-content-config-is-app-owned.md +27 -0
  3. package/layers/core/app/components/Element.vue +2 -66
  4. package/layers/core/app/components/ElementTw.vue +2 -66
  5. package/layers/core/app/composables/useElementStyle.ts +5 -59
  6. package/layers/core/app/composables/useLayoutAttrs.ts +20 -20
  7. package/layers/core/app/types/element.ts +41 -18
  8. package/layers/core/app/utils/resolveElementStyle.test.ts +99 -0
  9. package/layers/core/app/utils/resolveElementStyle.ts +52 -0
  10. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +1 -0
  11. package/layers/data/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +0 -10
  12. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +12 -11
  13. package/layers/data/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +0 -7
  14. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +12 -23
  15. package/layers/data/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +0 -10
  16. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +4 -7
  17. package/layers/data/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +0 -6
  18. package/layers/data/metadata/server/api/metadata/lookup.get.ts +7 -42
  19. package/layers/data/metadata/server/api/metadata/sync.post.ts +4 -49
  20. package/layers/data/metadata/server/utils/metadata/provider-fetch.test.ts +27 -0
  21. package/layers/data/metadata/server/utils/metadata/provider-fetch.ts +14 -0
  22. package/layers/data/metadata/server/utils/metadata/resolve-record.test.ts +87 -0
  23. package/layers/data/metadata/server/utils/metadata/resolve-record.ts +60 -0
  24. package/layers/data/metadata/shared/types/providers.ts +3 -2
  25. package/layers/design-system/theming/nuxt.config.ts +1 -1
  26. package/layers/design-system/typography/app/components/Typography/CodeBlock.vue +1 -1
  27. package/layers/design-system/typography/app/components/Typography/Headline.vue +1 -1
  28. package/layers/design-system/typography/app/components/Typography/QuoteBlock.vue +1 -1
  29. package/layers/design-system/typography/app/components/Typography/index.vue +1 -1
  30. package/layers/design-system/typography/app/composables/color.test.ts +30 -0
  31. package/layers/design-system/typography/app/composables/color.ts +9 -2
  32. package/layers/design-system/visual/app/composables/tint.test.ts +22 -0
  33. package/layers/design-system/visual/app/composables/tint.ts +6 -7
  34. package/layers/render/shader/nuxt.config.ts +1 -1
  35. package/layers/structure/navigation/app/components/Links/Named.vue +1 -1
  36. package/package.json +5 -4
  37. package/layers/core/app/composables/useElementGrid.test.ts +0 -33
  38. package/layers/core/app/composables/useElementGrid.ts +0 -29
  39. package/layers/core/app/composables/useElementInteraction.test.ts +0 -27
  40. package/layers/core/app/composables/useElementInteraction.ts +0 -17
  41. package/layers/core/app/composables/useElementLayout.test.ts +0 -41
  42. package/layers/core/app/composables/useElementLayout.ts +0 -26
  43. package/layers/core/app/composables/useElementSizing.test.ts +0 -39
  44. package/layers/core/app/composables/useElementSizing.ts +0 -25
  45. package/layers/core/app/composables/useElementSurface.test.ts +0 -33
  46. package/layers/core/app/composables/useElementSurface.ts +0 -21
  47. package/layers/design-system/theming/app/composables/useThemePreferences.ts +0 -5
@@ -1,3 +1,4 @@
1
+ import { providerFetch } from '#layers/metadata/server/utils/metadata/provider-fetch'
1
2
  import type { OpenLibraryEdition, OpenLibrarySearchResponse, OpenLibraryWork } from './types'
2
3
 
3
4
  const BASE = 'https://openlibrary.org'
@@ -10,35 +11,23 @@ export async function searchOpenLibrary(
10
11
  query: string,
11
12
  limit = 10
12
13
  ): Promise<OpenLibrarySearchResponse> {
13
- try {
14
- return await $fetch<OpenLibrarySearchResponse>(`${BASE}/search.json`, {
15
- timeout: 10000,
16
- query: {
17
- q: query,
18
- limit,
19
- fields:
20
- 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
21
- },
22
- })
23
- } catch (err) {
24
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
25
- }
14
+ return providerFetch<OpenLibrarySearchResponse>('openlibrary', `${BASE}/search.json`, {
15
+ timeout: 10000,
16
+ query: {
17
+ q: query,
18
+ limit,
19
+ fields:
20
+ 'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
21
+ },
22
+ })
26
23
  }
27
24
 
28
25
  export async function lookupOpenLibraryWork(workId: string): Promise<OpenLibraryWork> {
29
- try {
30
- return await $fetch<OpenLibraryWork>(`${BASE}/works/${workId}.json`)
31
- } catch (err) {
32
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
33
- }
26
+ return providerFetch<OpenLibraryWork>('openlibrary', `${BASE}/works/${workId}.json`)
34
27
  }
35
28
 
36
29
  export async function lookupOpenLibraryEdition(editionId: string): Promise<OpenLibraryEdition> {
37
- try {
38
- return await $fetch<OpenLibraryEdition>(`${BASE}/books/${editionId}.json`)
39
- } catch (err) {
40
- throw new MetadataProviderError('openlibrary', `API error: ${String(err)}`)
41
- }
30
+ return providerFetch<OpenLibraryEdition>('openlibrary', `${BASE}/books/${editionId}.json`)
42
31
  }
43
32
 
44
33
  export async function lookupByIsbn(isbn: string): Promise<OpenLibraryEdition | null> {
@@ -24,14 +24,4 @@ export const openLibraryProvider: MetadataProvider = {
24
24
  const work = await lookupOpenLibraryWork(providerId)
25
25
  return normaliseOpenLibraryWork(work)
26
26
  },
27
-
28
- async sync({ providerId, resourceType = 'work' }) {
29
- const record = await openLibraryProvider.lookup({
30
- provider: 'openlibrary',
31
- providerId,
32
- resourceType,
33
- })
34
- if (!record) throw new MetadataProviderError('openlibrary', `No record found for ${providerId}`)
35
- return record
36
- },
37
27
  }
@@ -1,3 +1,4 @@
1
+ import { providerFetch } from '#layers/metadata/server/utils/metadata/provider-fetch'
1
2
  import { useRuntimeConfig } from 'nitropack/runtime'
2
3
  import type { TMDBFindResponse, TMDBMovie, TMDBSearchResponse, TMDBTVShow } from './types'
3
4
 
@@ -22,13 +23,9 @@ async function tmdbFetch<T>(path: string, params: Record<string, string> = {}):
22
23
  if (!isJwt) url.searchParams.set('api_key', apiKey)
23
24
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
24
25
 
25
- try {
26
- return await $fetch<T>(url.toString(), {
27
- ...(isJwt && { headers: { Authorization: `Bearer ${apiKey}` } }),
28
- })
29
- } catch (err) {
30
- throw new MetadataProviderError('tmdb', `API error: ${String(err)}`)
31
- }
26
+ return providerFetch<T>('tmdb', url.toString(), {
27
+ ...(isJwt && { headers: { Authorization: `Bearer ${apiKey}` } }),
28
+ })
32
29
  }
33
30
 
34
31
  export async function searchTmdb(query: string, limit = 10): Promise<TMDBSearchResponse> {
@@ -32,10 +32,4 @@ export const tmdbProvider: MetadataProvider = {
32
32
  if (resourceType === 'tv-show') return normaliseTmdbTV(await fetchTmdbTV(providerId))
33
33
  return normaliseTmdbMovie(await fetchTmdbMovie(providerId))
34
34
  },
35
-
36
- async sync(input) {
37
- const record = await tmdbProvider.lookup(input)
38
- if (!record) throw new MetadataProviderError('tmdb', `No record found for ${input.providerId}`)
39
- return record
40
- },
41
35
  }
@@ -1,48 +1,13 @@
1
- import { getCacheRecord, makeCacheKey, setCacheRecord } from '../../utils/metadata/cache'
2
- import { getProvider } from '../../utils/metadata/provider-registry'
3
- import { createError, defineEventHandler, getQuery } from 'h3'
4
- import type { MetadataProviderId } from '#layers/metadata/shared/types'
1
+ import { resolveRecord } from '../../utils/metadata/resolve-record'
2
+ import { defineEventHandler, getQuery } from 'h3'
5
3
 
6
- // fallow-ignore-next-line complexity
7
4
  export default defineEventHandler(async (event) => {
8
5
  const query = getQuery(event)
9
6
 
10
- const providerId = String(query.providerId ?? '').trim()
11
- const providerName = String(query.provider ?? '').trim() as MetadataProviderId
12
- const resourceType = query.resourceType ? String(query.resourceType) : undefined
13
-
14
- if (!providerName || !providerId) {
15
- throw createError({ statusCode: 400, message: 'provider and providerId are required' })
16
- }
17
-
18
- const provider = getProvider(providerName)
19
- if (!provider)
20
- throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
21
-
22
- const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
23
- const cached = await getCacheRecord(cacheKey)
24
- if (cached) return cached.normalised
25
-
26
- const result = await provider.lookup({
27
- provider: providerName,
28
- providerId,
29
- ...(resourceType && { resourceType }),
7
+ const { record } = await resolveRecord({
8
+ provider: String(query.provider ?? '').trim(),
9
+ providerId: String(query.providerId ?? '').trim(),
10
+ ...(query.resourceType && { resourceType: String(query.resourceType) }),
30
11
  })
31
- if (!result) throw createError({ statusCode: 404, message: 'Not found' })
32
-
33
- const now = new Date().toISOString()
34
- await setCacheRecord(cacheKey, {
35
- id: result.id,
36
- provider: providerName,
37
- providerId,
38
- resourceType: resourceType ?? 'item',
39
- mediaType: result.mediaType,
40
- normalised: result,
41
- raw: result.raw ?? null,
42
- createdAt: now,
43
- updatedAt: now,
44
- lastSyncedAt: now,
45
- })
46
-
47
- return result
12
+ return record
48
13
  })
@@ -1,53 +1,8 @@
1
- import { getCacheRecord, makeCacheKey, setCacheRecord } from '../../utils/metadata/cache'
2
- import { getProvider } from '../../utils/metadata/provider-registry'
3
- import { createError, defineEventHandler, readBody } from 'h3'
4
- import type { MetadataProviderId, MetadataSyncInput } from '#layers/metadata/shared/types'
1
+ import { resolveRecord } from '../../utils/metadata/resolve-record'
2
+ import { defineEventHandler, readBody } from 'h3'
3
+ import type { MetadataSyncInput } from '#layers/metadata/shared/types'
5
4
 
6
- // fallow-ignore-next-line complexity
7
5
  export default defineEventHandler(async (event) => {
8
6
  const body = await readBody<MetadataSyncInput>(event)
9
-
10
- const { provider: providerName, providerId, resourceType, force } = body ?? {}
11
- if (!providerName || !providerId) {
12
- throw createError({ statusCode: 400, message: 'provider and providerId are required' })
13
- }
14
-
15
- const provider = getProvider(providerName as MetadataProviderId)
16
- if (!provider)
17
- throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
18
- if (!provider.sync)
19
- throw createError({
20
- statusCode: 400,
21
- message: `Provider '${providerName}' does not support sync`,
22
- })
23
-
24
- const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
25
-
26
- if (!force) {
27
- const cached = await getCacheRecord(cacheKey)
28
- if (cached) return { synced: false, record: cached.normalised }
29
- }
30
-
31
- const result = await provider.sync({
32
- provider: providerName as MetadataProviderId,
33
- providerId,
34
- ...(resourceType && { resourceType }),
35
- ...(force !== undefined && { force }),
36
- })
37
- const now = new Date().toISOString()
38
-
39
- await setCacheRecord(cacheKey, {
40
- id: result.id,
41
- provider: providerName as MetadataProviderId,
42
- providerId,
43
- resourceType: resourceType ?? 'item',
44
- mediaType: result.mediaType,
45
- normalised: result,
46
- raw: result.raw ?? null,
47
- createdAt: now,
48
- updatedAt: now,
49
- lastSyncedAt: now,
50
- })
51
-
52
- return { synced: true, record: result }
7
+ return resolveRecord({ ...body })
53
8
  })
@@ -0,0 +1,27 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
2
+
3
+ import { MetadataProviderError } from './errors'
4
+ import { providerFetch } from './provider-fetch'
5
+
6
+ afterEach(() => vi.unstubAllGlobals())
7
+
8
+ describe('providerFetch', () => {
9
+ it('returns the fetched body', async () => {
10
+ vi.stubGlobal(
11
+ '$fetch',
12
+ vi.fn(async () => ({ ok: true }))
13
+ )
14
+ await expect(providerFetch('tmdb', 'https://x.test')).resolves.toEqual({ ok: true })
15
+ })
16
+
17
+ it('wraps failures in a MetadataProviderError for the provider', async () => {
18
+ vi.stubGlobal(
19
+ '$fetch',
20
+ vi.fn(async () => Promise.reject(new Error('boom')))
21
+ )
22
+ const err = await providerFetch('tmdb', 'https://x.test').catch((e) => e)
23
+ expect(err).toBeInstanceOf(MetadataProviderError)
24
+ expect(err.provider).toBe('tmdb')
25
+ expect(err.message).toContain('API error')
26
+ })
27
+ })
@@ -0,0 +1,14 @@
1
+ import { MetadataProviderError } from './errors'
2
+
3
+ /** `$fetch` that reports any failure as a MetadataProviderError for `provider`. */
4
+ export async function providerFetch<T>(
5
+ provider: string,
6
+ url: string,
7
+ opts?: { query?: Record<string, unknown>; headers?: Record<string, string>; timeout?: number }
8
+ ): Promise<T> {
9
+ try {
10
+ return (await $fetch(url, opts)) as T
11
+ } catch (err) {
12
+ throw new MetadataProviderError(provider, `API error: ${String(err)}`, err)
13
+ }
14
+ }
@@ -0,0 +1,87 @@
1
+ import type { MetadataProvider, MetadataRecord } from '#layers/metadata/shared/types'
2
+ import type * as CacheModule from './cache'
3
+ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
4
+
5
+ const { providerState, getCacheRecordMock, setCacheRecordMock } = vi.hoisted(() => ({
6
+ providerState: { provider: null as MetadataProvider | null },
7
+ getCacheRecordMock: vi.fn(async () => null as { normalised: MetadataRecord } | null),
8
+ setCacheRecordMock: vi.fn(async () => undefined),
9
+ }))
10
+
11
+ vi.mock('./provider-registry', () => ({ getProvider: () => providerState.provider }))
12
+ vi.mock('./cache', async (importOriginal) => ({
13
+ ...(await importOriginal<typeof CacheModule>()),
14
+ getCacheRecord: getCacheRecordMock,
15
+ setCacheRecord: setCacheRecordMock,
16
+ }))
17
+
18
+ import { resolveRecord } from './resolve-record'
19
+
20
+ const record = {
21
+ id: 'r1',
22
+ provider: 'comicvine',
23
+ providerId: '42',
24
+ mediaType: 'comic',
25
+ title: 'Watchmen',
26
+ } as MetadataRecord
27
+
28
+ const lookup = vi.fn(async () => record as MetadataRecord | null)
29
+
30
+ beforeEach(() => {
31
+ vi.clearAllMocks()
32
+ getCacheRecordMock.mockResolvedValue(null)
33
+ lookup.mockResolvedValue(record)
34
+ providerState.provider = {
35
+ id: 'comicvine',
36
+ label: 'Comic Vine',
37
+ mediaTypes: ['comic'],
38
+ search: async () => [],
39
+ lookup,
40
+ }
41
+ })
42
+
43
+ describe('resolveRecord', () => {
44
+ it('400s without provider or providerId', async () => {
45
+ await expect(resolveRecord({ provider: 'comicvine' })).rejects.toMatchObject({
46
+ statusCode: 400,
47
+ })
48
+ })
49
+
50
+ it('404s for an unregistered provider', async () => {
51
+ providerState.provider = null
52
+ await expect(resolveRecord({ provider: 'nope', providerId: '1' })).rejects.toMatchObject({
53
+ statusCode: 404,
54
+ })
55
+ })
56
+
57
+ it('fetches, caches and reports synced on a miss', async () => {
58
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42' })
59
+ expect(out).toEqual({ record, synced: true })
60
+ expect(setCacheRecordMock).toHaveBeenCalledWith(
61
+ 'metadata:comicvine:item:42',
62
+ expect.objectContaining({ id: 'r1', providerId: '42', resourceType: 'item' })
63
+ )
64
+ })
65
+
66
+ it('returns the cached record without calling the provider', async () => {
67
+ getCacheRecordMock.mockResolvedValue({ normalised: record })
68
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42' })
69
+ expect(out).toEqual({ record, synced: false })
70
+ expect(lookup).not.toHaveBeenCalled()
71
+ })
72
+
73
+ it('force skips the cache read', async () => {
74
+ getCacheRecordMock.mockResolvedValue({ normalised: record })
75
+ const out = await resolveRecord({ provider: 'comicvine', providerId: '42', force: true })
76
+ expect(out.synced).toBe(true)
77
+ expect(lookup).toHaveBeenCalledOnce()
78
+ })
79
+
80
+ it('404s when the provider finds nothing and does not cache', async () => {
81
+ lookup.mockResolvedValue(null)
82
+ await expect(resolveRecord({ provider: 'comicvine', providerId: '42' })).rejects.toMatchObject({
83
+ statusCode: 404,
84
+ })
85
+ expect(setCacheRecordMock).not.toHaveBeenCalled()
86
+ })
87
+ })
@@ -0,0 +1,60 @@
1
+ import { createError } from 'h3'
2
+ import { getCacheRecord, makeCacheKey, setCacheRecord } from './cache'
3
+ import { getProvider } from './provider-registry'
4
+ import type { MetadataRecord } from '#layers/metadata/shared/types'
5
+
6
+ export type ResolveRecordInput = {
7
+ provider?: string
8
+ providerId?: string
9
+ resourceType?: string
10
+ /** Skip the cache read and refetch from the provider. */
11
+ force?: boolean
12
+ }
13
+
14
+ /**
15
+ * Cache-through lookup of one record. `synced` is true when the provider was called,
16
+ * false when the cached record was returned. Throws h3 errors (400/404) for bad input.
17
+ */
18
+ export async function resolveRecord(
19
+ input: ResolveRecordInput
20
+ ): Promise<{ record: MetadataRecord; synced: boolean }> {
21
+ const { provider: providerName, providerId, resourceType, force } = input
22
+ if (!providerName || !providerId) {
23
+ throw createError({ statusCode: 400, message: 'provider and providerId are required' })
24
+ }
25
+
26
+ const provider = getProvider(providerName)
27
+ if (!provider) {
28
+ throw createError({ statusCode: 404, message: `Provider '${providerName}' not registered` })
29
+ }
30
+
31
+ const cacheKey = makeCacheKey('metadata', providerName, resourceType ?? 'item', providerId)
32
+
33
+ if (!force) {
34
+ const cached = await getCacheRecord(cacheKey)
35
+ if (cached) return { record: cached.normalised, synced: false }
36
+ }
37
+
38
+ const record = await provider.lookup({
39
+ provider: providerName,
40
+ providerId,
41
+ ...(resourceType && { resourceType }),
42
+ })
43
+ if (!record) throw createError({ statusCode: 404, message: 'Not found' })
44
+
45
+ const now = new Date().toISOString()
46
+ await setCacheRecord(cacheKey, {
47
+ id: record.id,
48
+ provider: providerName,
49
+ providerId,
50
+ resourceType: resourceType ?? 'item',
51
+ mediaType: record.mediaType,
52
+ normalised: record,
53
+ raw: record.raw ?? null,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ lastSyncedAt: now,
57
+ })
58
+
59
+ return { record, synced: true }
60
+ }
@@ -1,6 +1,8 @@
1
1
  import type { MetadataMediaType, MetadataRecord } from './metadata'
2
2
 
3
- export type MetadataProviderId = 'comicvine' | 'openlibrary' | 'google-books' | 'tmdb' | 'local'
3
+ // Open: a provider layer registers whatever id it likes (built-ins: comicvine, openlibrary,
4
+ // google-books, tmdb). Unknown ids are rejected at runtime by the provider registry.
5
+ export type MetadataProviderId = string
4
6
 
5
7
  export type MetadataSearchInput = {
6
8
  query: string
@@ -32,5 +34,4 @@ export type MetadataProvider = {
32
34
  mediaTypes: MetadataMediaType[]
33
35
  search: (input: MetadataSearchInput) => Promise<MetadataSearchResult[]>
34
36
  lookup: (input: MetadataLookupInput) => Promise<MetadataRecord | null>
35
- sync?: (input: MetadataSyncInput) => Promise<MetadataRecord>
36
37
  }
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
12
12
  name: 'theming',
13
13
  },
14
14
 
15
- extends: ['../../core'],
15
+ extends: ['../../core', '../typography'],
16
16
 
17
17
  runtimeConfig: {
18
18
  public: {
@@ -54,7 +54,7 @@
54
54
  ...(size !== undefined && { size }),
55
55
  ...(fluidSize !== undefined && { fluidSize }),
56
56
  }))
57
- const colorClass = useColor(color, 'text')
57
+ const colorClass = useColor(() => color, 'text')
58
58
  </script>
59
59
 
60
60
  <template>
@@ -71,7 +71,7 @@
71
71
  ...(size !== undefined && { size }),
72
72
  ...(appliedFluidSize.value !== undefined && { fluidSize: appliedFluidSize.value }),
73
73
  }))
74
- const colorClass = useColor(color, 'text')
74
+ const colorClass = useColor(() => color, 'text')
75
75
  </script>
76
76
 
77
77
  <template>
@@ -52,7 +52,7 @@
52
52
  ...(size !== undefined && { size }),
53
53
  ...(fluidSize !== undefined && { fluidSize }),
54
54
  }))
55
- const colorClass = useColor(color, 'text')
55
+ const colorClass = useColor(() => color, 'text')
56
56
  </script>
57
57
 
58
58
  <template>
@@ -55,7 +55,7 @@
55
55
  ...(font !== undefined && { font }),
56
56
  ...(fluidSize !== undefined && { fluidSize }),
57
57
  }))
58
- const colorClass = useColor(color, 'text')
58
+ const colorClass = useColor(() => color, 'text')
59
59
  </script>
60
60
 
61
61
  <template>
@@ -0,0 +1,30 @@
1
+ import { ref } from 'vue'
2
+ import { describe, expect, it } from 'vite-plus/test'
3
+
4
+ import { useColor } from './color'
5
+
6
+ describe('useColor', () => {
7
+ it('builds a class from a plain value', () => {
8
+ expect(useColor('primary', 'text').value).toBe('text-primary')
9
+ })
10
+
11
+ it('falls back to default when color is undefined', () => {
12
+ expect(useColor(undefined, 'bg').value).toBe('bg-default')
13
+ })
14
+
15
+ it('tracks a ref', () => {
16
+ const color = ref<'primary' | 'error'>('primary')
17
+ const cls = useColor(color)
18
+ color.value = 'error'
19
+ expect(cls.value).toBe('text-error')
20
+ })
21
+
22
+ it('tracks a getter', () => {
23
+ let color: 'primary' | 'error' = 'primary'
24
+ const state = ref(0)
25
+ const cls = useColor(() => (state.value, color))
26
+ color = 'error'
27
+ state.value++
28
+ expect(cls.value).toBe('text-error')
29
+ })
30
+ })
@@ -1,5 +1,12 @@
1
+ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
2
+
1
3
  import type { ColorUsage, UiColors } from '../types/colors'
2
4
 
3
- export function useColor(color?: UiColors, type: ColorUsage = 'text') {
4
- return computed(() => `${type}-${color ?? 'default'}`)
5
+ // Takes a getter (`() => color`) when `color` is a destructured prop — passing the bare
6
+ // value reads it once at setup and the class never updates.
7
+ export function useColor(
8
+ color?: MaybeRefOrGetter<UiColors | undefined>,
9
+ type: ColorUsage = 'text'
10
+ ) {
11
+ return computed(() => `${type}-${toValue(color) ?? 'default'}`)
5
12
  }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vite-plus/test'
2
+
3
+ import { useTint } from './tint'
4
+
5
+ describe('useTint', () => {
6
+ it('resolves a slot to its ui color token with the level opacity', () => {
7
+ expect(useTint({ color: 'primary', level: 'light' }).style.value).toEqual({
8
+ backgroundColor: 'var(--ui-color-primary-500)',
9
+ opacity: 0.1,
10
+ })
11
+ })
12
+
13
+ it('honours shade and the white/black literals', () => {
14
+ expect(
15
+ useTint({ color: 'error', shade: 200, level: 'subtle' }).style.value.backgroundColor
16
+ ).toBe('var(--ui-color-error-200)')
17
+ expect(useTint({ color: 'white', level: 'heavy' }).style.value).toEqual({
18
+ backgroundColor: '#ffffff',
19
+ opacity: 0.5,
20
+ })
21
+ })
22
+ })
@@ -1,18 +1,17 @@
1
- import { toValue, type ComputedRef, type CSSProperties, type MaybeRefOrGetter } from 'vue'
1
+ import { computed, toValue, type ComputedRef, type CSSProperties, type MaybeRefOrGetter } from 'vue'
2
2
 
3
3
  import { TINT_LEVEL_OPACITY, type TintConfig } from '../types/tint'
4
+ import { resolveUiColorToken } from '../utils/colorTokens'
4
5
 
5
6
  export function useTint(config: MaybeRefOrGetter<TintConfig>): {
6
7
  style: ComputedRef<CSSProperties>
7
8
  } {
8
9
  const style = computed((): CSSProperties => {
9
10
  const { color, shade = 500, level } = toValue(config)
10
- let backgroundColor = `var(--ui-color-${color}-${shade})`
11
- if (color === 'white') backgroundColor = '#ffffff'
12
- if (color === 'black') backgroundColor = '#000000'
13
-
14
- const opacity = TINT_LEVEL_OPACITY[level] / 100
15
- return { backgroundColor, opacity }
11
+ return {
12
+ backgroundColor: resolveUiColorToken({ color, shade }),
13
+ opacity: TINT_LEVEL_OPACITY[level] / 100,
14
+ }
16
15
  })
17
16
 
18
17
  return { style }
@@ -5,7 +5,7 @@ export default defineNuxtConfig({
5
5
 
6
6
  compatibilityDate: '2025-07-15',
7
7
 
8
- extends: ['../canvas'],
8
+ extends: ['../canvas', '../../design-system/theming'],
9
9
 
10
10
  typescript: {
11
11
  typeCheck: false,
@@ -17,7 +17,7 @@
17
17
  variant?: string
18
18
  color?: UiColors
19
19
  }>()
20
- const colorClass = useColor(color, 'text')
20
+ const colorClass = useColor(() => color, 'text')
21
21
  // `name` is a plain string here, not one of the app's typed route names
22
22
  const to = computed(
23
23
  () => ({ name, params: params ?? {}, query: query ?? {} }) as unknown as LinkProps['to']
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kmcom-nuxt-layers",
3
3
  "private": false,
4
- "version": "5.0.7",
4
+ "version": "6.0.0",
5
5
  "description": "Composable Nuxt 4 layers for building scalable Vue applications",
6
6
  "exports": {
7
7
  "./layers/core": "./layers/core/nuxt.config.ts",
@@ -11,6 +11,7 @@
11
11
  "./layers/navigation": "./layers/structure/navigation/nuxt.config.ts",
12
12
  "./layers/visual": "./layers/design-system/visual/nuxt.config.ts",
13
13
  "./layers/layout": "./layers/structure/layout/nuxt.config.ts",
14
+ "./layers/grid": "./layers/structure/grid/nuxt.config.ts",
14
15
  "./layers/scroll": "./layers/motion/scroll/nuxt.config.ts",
15
16
  "./layers/animations": "./layers/motion/animations/nuxt.config.ts",
16
17
  "./layers/transitions": "./layers/motion/transitions/nuxt.config.ts",
@@ -167,7 +168,7 @@
167
168
  "better-sqlite3": "^12.10.0",
168
169
  "@perplex-digital/stylelint-config": "^17.4.0",
169
170
  "@pinia/nuxt": "^0.11.3",
170
- "@playwright/test": "1.61.0",
171
+ "@playwright/test": "1.64.1",
171
172
  "@types/node": "^25.9.1",
172
173
  "@types/three": "^0.184.1",
173
174
  "@typescript-eslint/eslint-plugin": "^8.60.1",
@@ -207,8 +208,8 @@
207
208
  "vite-plugin-checker": "^0.14.5",
208
209
  "vitest": "5.0.5",
209
210
  "vue": "3.5.35",
210
- "vue-tsc": "3.3.11",
211
- "zod": "^4.4.3",
211
+ "vue-tsc": "3.3.13",
212
+ "zod": "^4.3.6",
212
213
  "zod-to-json-schema": "^3.25.2",
213
214
  "vite-plus": "1.3.0"
214
215
  },