@nuxtseo/design-system 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +83 -17
  2. package/app/components/container/UiAlert.vue +4 -2
  3. package/app/components/container/UiErrorState.vue +5 -3
  4. package/app/components/container/UiSectionHeader.vue +12 -2
  5. package/app/components/container/UiToast.vue +1 -37
  6. package/app/components/container/UiToaster.vue +1 -11
  7. package/app/components/container/UiWidgetState.vue +7 -5
  8. package/app/components/data/UiDataList.vue +6 -4
  9. package/app/components/data/UiMetricLabel.vue +3 -1
  10. package/app/components/data/UiStat.vue +1 -60
  11. package/app/components/data/UiStats.vue +1 -1
  12. package/app/components/data/UiTable.vue +16 -52
  13. package/app/components/data/UiTableTd.vue +9 -1
  14. package/app/components/data/UiTableTh.vue +10 -2
  15. package/app/components/data/cells/UiTableHeaderCell.vue +1 -1
  16. package/app/components/element/UiAtmosphere.vue +22 -4
  17. package/app/components/element/UiConfirmDialog.vue +54 -7
  18. package/app/components/element/UiOnboardingAnswers.vue +78 -43
  19. package/app/components/element/UiSkeleton.vue +50 -1
  20. package/app/components/element/UiTooltip.vue +7 -2
  21. package/app/components/element/UiTrend.vue +16 -8
  22. package/app/composables/design-system-config.ts +29 -0
  23. package/app/composables/useConfirm.ts +31 -3
  24. package/app/shared/error-state.ts +13 -12
  25. package/app/shared/failure-copy.ts +53 -0
  26. package/app/shared/stat.ts +65 -0
  27. package/app/shared/table.ts +74 -1
  28. package/app/shared/toast.ts +52 -0
  29. package/app/shared/widget-failure.ts +3 -2
  30. package/build/design-system.ts +48 -12
  31. package/css/global.css +46 -9
  32. package/nuxt.config.ts +2 -20
  33. package/package.json +6 -5
  34. package/build/fonts.ts +0 -39
  35. package/public/fonts/README.md +0 -33
  36. package/public/fonts/fira-code-OFL.txt +0 -93
  37. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  38. package/public/fonts/hubot-sans-OFL.txt +0 -93
  39. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
@@ -1,3 +1,4 @@
1
+ import type { VNodeChild } from 'vue'
1
2
  import { useOverlay } from '#imports'
2
3
  import UiConfirmDialog from '../components/element/UiConfirmDialog.vue'
3
4
 
@@ -13,6 +14,27 @@ export interface ConfirmOptions {
13
14
  readonly confirmLabel: string
14
15
  readonly cancelLabel?: string
15
16
  readonly tone?: ConfirmTone
17
+ /** Rich content under the description: `() => h('p', [h('strong', site.name), ' stops syncing.'])`. */
18
+ readonly body?: () => VNodeChild
19
+ /**
20
+ * The work to run on confirm. The dialog shows its loading state until the
21
+ * action settles, then closes. A rejected action rejects the returned
22
+ * promise with the same error.
23
+ */
24
+ readonly action?: () => Promise<unknown>
25
+ }
26
+
27
+ /** How the dialog closed. Nuxt UI's overlay hands it to `useConfirm()`. */
28
+ export type ConfirmResult
29
+ = | { readonly _tag: 'Confirmed' }
30
+ | { readonly _tag: 'Cancelled' }
31
+ | { readonly _tag: 'ActionFailed', readonly error: unknown }
32
+
33
+ /** Escape and a click outside close the overlay with no result. */
34
+ function toAnswer(result: ConfirmResult | undefined): boolean {
35
+ if (result?._tag === 'ActionFailed')
36
+ throw result.error
37
+ return result?._tag === 'Confirmed'
16
38
  }
17
39
 
18
40
  /**
@@ -25,8 +47,14 @@ export interface ConfirmOptions {
25
47
  * ```
26
48
  *
27
49
  * Resolves `true` only for the confirm button. Cancel, Escape, and a click
28
- * outside resolve `false`. Call it in `setup`; the returned function may run
29
- * any time later. Needs `<UApp>`, which mounts Nuxt UI's overlay provider.
50
+ * outside resolve `false`. Pass `action` to run the work inside the dialog:
51
+ *
52
+ * ```ts
53
+ * await confirm({ title: 'Remove Site?', confirmLabel: 'Remove', action: () => removeSite(id) })
54
+ * ```
55
+ *
56
+ * Call it in `setup`; the returned function may run any time later. Needs
57
+ * `<UApp>`, which mounts Nuxt UI's overlay provider.
30
58
  */
31
59
  export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
32
60
  const overlay = useOverlay()
@@ -34,5 +62,5 @@ export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
34
62
  // a page that asks many times holds no dialog it is not showing.
35
63
  return options => overlay.create(UiConfirmDialog, { destroyOnClose: true })
36
64
  .open({ ...options })
37
- .then(result => result === true)
65
+ .then(result => toAnswer(result as ConfirmResult | undefined))
38
66
  }
@@ -1,5 +1,7 @@
1
+ import type { FailureCopy } from './failure-copy'
1
2
  import type { UiIcon } from './ui-icons'
2
- import { toWidgetFailure, WIDGET_FAILURE_COPY } from './widget-failure'
3
+ import { FAILURE_COPY } from './failure-copy'
4
+ import { toWidgetFailure } from './widget-failure'
3
5
 
4
6
  /**
5
7
  * What `UiErrorState` is allowed to say, and where the words came from.
@@ -87,8 +89,6 @@ export interface ErrorStateView {
87
89
  readonly retryable: boolean
88
90
  }
89
91
 
90
- const GENERIC_NEXT_STEP = 'Try again in a moment. If it keeps happening, contact support.'
91
-
92
92
  function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number): string | null {
93
93
  if (source.retryAfter)
94
94
  return `Try again in ${source.retryAfter} seconds`
@@ -108,16 +108,17 @@ function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number)
108
108
 
109
109
  /**
110
110
  * Turn a source into the words on screen. Pure: pass the clock in, so the
111
- * reset hint is testable and the component owns no time.
111
+ * reset hint is testable and the component owns no time. Pass `copy` to use a
112
+ * site's reworded failures (`readFailureCopy()`).
112
113
  */
113
- export function resolveErrorState(source: ErrorStateSource, now: number): ErrorStateView {
114
+ export function resolveErrorState(source: ErrorStateSource, now: number, copy: FailureCopy = FAILURE_COPY): ErrorStateView {
114
115
  if (source._tag === 'copy') {
115
116
  return {
116
117
  tone: 'error',
117
118
  icon: 'caution',
118
119
  title: source.title,
119
120
  detail: source.detail ?? null,
120
- nextStep: { _tag: 'generic', text: GENERIC_NEXT_STEP },
121
+ nextStep: { _tag: 'generic', text: copy.nextStep },
121
122
  retryable: true,
122
123
  }
123
124
  }
@@ -127,24 +128,24 @@ export function resolveErrorState(source: ErrorStateSource, now: number): ErrorS
127
128
  return {
128
129
  tone: 'warning',
129
130
  icon: 'clock',
130
- title: WIDGET_FAILURE_COPY['rate-limited'].title,
131
+ title: copy.failures['rate-limited'].title,
131
132
  detail: source.detail,
132
133
  nextStep: hint
133
134
  ? { _tag: 'timing', text: hint }
134
- : { _tag: 'generic', text: WIDGET_FAILURE_COPY['rate-limited'].body },
135
+ : { _tag: 'generic', text: copy.failures['rate-limited'].body },
135
136
  retryable: true,
136
137
  }
137
138
  }
138
139
 
139
140
  const failure = toWidgetFailure(source.error)
140
- const copy = WIDGET_FAILURE_COPY[failure._tag]
141
+ const failureCopy = copy.failures[failure._tag]
141
142
  const limited = failure._tag === 'rate-limited'
142
143
  return {
143
144
  tone: limited ? 'warning' : 'error',
144
145
  icon: limited ? 'clock' : 'caution',
145
- title: copy.title,
146
- detail: copy.body,
146
+ title: failureCopy.title,
147
+ detail: failureCopy.body,
147
148
  nextStep: null,
148
- retryable: copy.retryable,
149
+ retryable: failureCopy.retryable,
149
150
  }
150
151
  }
@@ -0,0 +1,53 @@
1
+ import type { WidgetFailureCopy, WidgetFailureTag } from './widget-failure'
2
+ import { WIDGET_FAILURE_COPY } from './widget-failure'
3
+
4
+ /**
5
+ * Every string the failure surfaces render: `UiWidgetState`, `UiDataList` and
6
+ * `UiErrorState`. COPY.md (Shared primitive assets) owns the defaults. A site
7
+ * that extends the layer rewords them through `designSystem.failureCopy` in its
8
+ * app.config. Whether a failure is retryable is behaviour, so no override
9
+ * reaches it.
10
+ */
11
+ export interface FailureCopy {
12
+ readonly failures: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>>
13
+ /** The retry button on `UiWidgetState` and `UiDataList`. */
14
+ readonly retryLabel: string
15
+ /** The retry button on `UiErrorState`. */
16
+ readonly errorStateRetryLabel: string
17
+ /** The next-step line `UiErrorState` shows under copy a caller wrote. */
18
+ readonly nextStep: string
19
+ }
20
+
21
+ /** What a site may reword. Each key it leaves out keeps the default. */
22
+ export interface FailureCopyOverrides {
23
+ readonly failures?: { readonly [K in WidgetFailureTag]?: Partial<Pick<WidgetFailureCopy, 'title' | 'body'>> }
24
+ readonly retryLabel?: string
25
+ readonly errorStateRetryLabel?: string
26
+ readonly nextStep?: string
27
+ }
28
+
29
+ export const FAILURE_COPY: FailureCopy = {
30
+ failures: WIDGET_FAILURE_COPY,
31
+ retryLabel: 'Retry',
32
+ errorStateRetryLabel: 'Try again',
33
+ nextStep: 'Try again in a moment. If it keeps happening, contact support.',
34
+ }
35
+
36
+ const FAILURE_TAGS = Object.keys(WIDGET_FAILURE_COPY) as WidgetFailureTag[]
37
+
38
+ /** Lay a site's overrides over the defaults. Pure, so a test passes its own. */
39
+ export function resolveFailureCopy(overrides: FailureCopyOverrides | undefined): FailureCopy {
40
+ if (!overrides)
41
+ return FAILURE_COPY
42
+ const failures = Object.fromEntries(FAILURE_TAGS.map((tag) => {
43
+ const base = FAILURE_COPY.failures[tag]
44
+ const own = overrides.failures?.[tag]
45
+ return [tag, { title: own?.title ?? base.title, body: own?.body ?? base.body, retryable: base.retryable }]
46
+ })) as Record<WidgetFailureTag, WidgetFailureCopy>
47
+ return {
48
+ failures,
49
+ retryLabel: overrides.retryLabel ?? FAILURE_COPY.retryLabel,
50
+ errorStateRetryLabel: overrides.errorStateRetryLabel ?? FAILURE_COPY.errorStateRetryLabel,
51
+ nextStep: overrides.nextStep ?? FAILURE_COPY.nextStep,
52
+ }
53
+ }
@@ -0,0 +1,65 @@
1
+ import type { SlotTextOptions } from '../utils/slot-text'
2
+ import type { UiIcon as UiIconName } from './ui-icons'
3
+
4
+ // `UiStat`'s props. They live in this module, not the SFC, so a consumer can
5
+ // name them in its own `defineProps`: the Vue SFC compiler resolves a type
6
+ // from a package `.ts` file and not from a package `.vue` file.
7
+
8
+ type Datum = Record<string, number | string>
9
+
10
+ export interface UiStatProps {
11
+ // link
12
+ to?: string
13
+
14
+ // label
15
+ icon?: UiIconName
16
+ title?: string
17
+ tooltip?: string
18
+ tooltipDescription?: string
19
+
20
+ // value
21
+ value?: string | number | null
22
+ suffix?: string
23
+ valueClass?: string
24
+ format?: (n: number) => string
25
+
26
+ // trend
27
+ trend?: number | null
28
+ /**
29
+ * Formats the trend magnitude. The value `format` never applies: the trend is
30
+ * a percent or a delta, so a currency formatter printed "+$12.5%".
31
+ */
32
+ trendFormat?: (n: number) => string
33
+ trendSuffix?: string
34
+ trendLabel?: string
35
+ invertTrend?: boolean
36
+ /**
37
+ * Trend color budget (DESIGN.md Composition): colored success/error trends
38
+ * belong to the hero zone only. Default renders neutral; `UiStats
39
+ * variant="cards"` (the hero pattern) opts its stats in automatically.
40
+ */
41
+ trendColored?: boolean
42
+
43
+ // sparkline
44
+ sparkline?: Datum[]
45
+
46
+ // sizing
47
+ size?: 'sm' | 'md' | 'lg'
48
+ /** Keep report-visible labels and deltas at the 14px readable tier. */
49
+ readableLabels?: boolean
50
+
51
+ // state
52
+ loading?: boolean
53
+
54
+ /**
55
+ * Rolls compact metric values when they change. Defaults on for numeric-ish
56
+ * values; pass false for dense/realtime surfaces or object options to tune.
57
+ */
58
+ animatedValue?: boolean | SlotTextOptions
59
+
60
+ // threshold alerting
61
+ status?: 'crisis' | 'warning' | 'good'
62
+
63
+ // render the value area inside a card, keeping the header outside
64
+ card?: boolean
65
+ }
@@ -1,4 +1,4 @@
1
- import type { ColumnDef } from '@tanstack/table-core'
1
+ import type { Cell, ColumnDef, Row, RowSelectionState } from '@tanstack/table-core'
2
2
  import {
3
3
  columnFilteringFeature,
4
4
  columnOrderingFeature,
@@ -103,3 +103,76 @@ export const uiTableVisibleFromClass: Record<UiTableVisibleFrom, string> = {
103
103
  'xl': 'hidden xl:table-cell',
104
104
  '2xl': 'hidden 2xl:table-cell',
105
105
  }
106
+
107
+ /**
108
+ * `UiTable`'s props. They live in this module, not the SFC, so a consumer that
109
+ * wraps the table can name them in its own `defineProps`. The Vue SFC compiler
110
+ * resolves a type from a package `.ts` file; it cannot resolve one from a
111
+ * package `.vue` file.
112
+ */
113
+ export interface UiTableProps<T extends object> {
114
+ data: T[]
115
+ columns: UiTableColumn<T>[]
116
+ selected?: RowSelectionState
117
+ controlledSelection?: boolean
118
+ rowHover?: boolean
119
+ rowClickable?: boolean
120
+ enableSorting?: boolean
121
+ /** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
122
+ manualSorting?: boolean
123
+ pageSize?: number
124
+ ignoreHeader?: boolean
125
+ size?: UiTableSize
126
+ loading?: boolean
127
+ loadingRows?: number
128
+ rowId?: UiTableRowId<T>
129
+ manualPagination?: boolean
130
+ disablePagination?: boolean
131
+ total?: number
132
+ /** Accessible name for the table. Rendered as a visually-hidden <caption>. */
133
+ label: string
134
+ /** Draw the canonical rounded table surface. */
135
+ bordered?: boolean
136
+ /** Pagination density. Compact renders previous, page position, and next only. */
137
+ paginationPreset?: 'default' | 'compact'
138
+ /** Fill the caller's available height and keep pagination pinned to the bottom. */
139
+ fill?: boolean
140
+ /** Extra classes applied per data row — e.g. a selection highlight. */
141
+ rowClass?: (row: T) => string
142
+ /** Row id presented as the active item in a click-through table. */
143
+ activeRowId?: string
144
+ /** Row id to open when the table first receives deep-linked evidence. */
145
+ initialExpandedRowId?: string
146
+ /**
147
+ * Show Export CSV and Export JSON, saving to this file name without the
148
+ * extension. The export holds every filtered and sorted row, not one page.
149
+ * Ignored with `manualPagination`: mount `UiTableExport` with every row.
150
+ */
151
+ exportFilename?: string
152
+ }
153
+
154
+ /** What a `#<column id>-cell` slot receives: the TanStack row and cell, and the cell's value. */
155
+ export interface UiTableCellSlotProps<T extends object> {
156
+ row: Row<UiTableFeatures, T>
157
+ cell: Cell<UiTableFeatures, T, unknown>
158
+ value: unknown
159
+ }
160
+
161
+ /**
162
+ * `UiTable`'s slots. A `#<column id>-cell` slot draws that column's cells and
163
+ * wins over the column's `cell` renderer. A column with no slot keeps its
164
+ * renderer. `#empty` replaces the zero-rows line.
165
+ */
166
+ export type UiTableSlots<T extends object> = {
167
+ [K in `${string}-cell`]?: (props: UiTableCellSlotProps<T>) => unknown
168
+ } & {
169
+ 'empty'?: () => unknown
170
+ 'expanded-component'?: (props: { row: T }) => unknown
171
+ 'actions'?: (props: { row: T }) => unknown
172
+ 'tfoot'?: () => unknown
173
+ }
174
+
175
+ /** The slot name that draws one column's cells. */
176
+ export function uiTableCellSlotName(columnId: string): `${string}-cell` {
177
+ return `${columnId}-cell`
178
+ }
@@ -0,0 +1,52 @@
1
+ import type { Component } from 'vue'
2
+ import type { SemanticStatus } from '../composables/semanticColors'
3
+
4
+ // `UiToast` and `UiToaster` props. They live in this module, not the SFCs, so
5
+ // a consumer can name them in its own `defineProps`: the Vue SFC compiler
6
+ // resolves a type from a package `.ts` file and not from a package `.vue` file.
7
+
8
+ export interface ToastAction {
9
+ label: string
10
+ icon?: string
11
+ to?: string
12
+ onClick?: (e: MouseEvent) => void
13
+ /** Visual emphasis. `primary` = filled, `neutral` = quiet. */
14
+ color?: 'primary' | 'neutral'
15
+ }
16
+
17
+ export interface UiToastProps {
18
+ /** Heading text (string or a render component). */
19
+ title?: string | Component
20
+ /** Body text (string or a render component). */
21
+ description?: string | Component
22
+ /** Semantic status — drives accent + default icon. Wins over `color`. */
23
+ status?: SemanticStatus
24
+ /** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
25
+ color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
26
+ /** Icon override. Defaults to the status icon. */
27
+ icon?: string
28
+ /** Inline action buttons. */
29
+ actions?: ToastAction[]
30
+ /**
31
+ * Bug-report handler. When set, failure toasts (error/warning) that carry no
32
+ * action of their own get a "Report bug" button. Wired by the app (the
33
+ * design system must not reach for the feedback drawer itself).
34
+ */
35
+ reportBug?: (toast: { title?: string, description?: string }) => void
36
+ /** Show the close button. */
37
+ close?: boolean
38
+ /** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
39
+ progress?: number | false
40
+ /** Collapsed back card — fade content so the deck reads as peeking cards. */
41
+ peek?: boolean
42
+ }
43
+
44
+ export interface UiToasterProps {
45
+ position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
46
+ /** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
47
+ max?: number
48
+ /** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
49
+ duration?: number
50
+ /** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
51
+ reportBug?: UiToastProps['reportBug']
52
+ }
@@ -121,6 +121,7 @@ export function toWidgetFailure(error: unknown): WidgetFailure {
121
121
  return { _tag: 'unknown' }
122
122
  }
123
123
 
124
- export function widgetFailureCopy(error: unknown): WidgetFailureCopy {
125
- return WIDGET_FAILURE_COPY[toWidgetFailure(error)._tag]
124
+ /** The copy for a caught value. Pass `copy` to use a site's reworded failures. */
125
+ export function widgetFailureCopy(error: unknown, copy: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>> = WIDGET_FAILURE_COPY): WidgetFailureCopy {
126
+ return copy[toWidgetFailure(error)._tag]
126
127
  }
@@ -1,7 +1,9 @@
1
1
  import type { ModuleOptions as IconModuleOptions } from '@nuxt/icon'
2
2
  import type { NuxtOptions } from 'nuxt/schema'
3
3
  import type { IconSet } from '../shared/icons'
4
- import { addTemplate, defineNuxtModule } from 'nuxt/kit'
4
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
5
+ import { defineNuxtModule } from 'nuxt/kit'
6
+ import { dirname, resolve } from 'pathe'
5
7
  import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
6
8
  import { externalLinkMarkerCss } from './external-links'
7
9
 
@@ -12,13 +14,12 @@ export interface DesignSystemOptions {
12
14
  */
13
15
  iconSet?: IconSet
14
16
  /**
15
- * Load Nuxt SEO's Hubot Sans and Fira Code faces as global families, so
16
- * every page preloads them. Defaults to false: a site sets its own families
17
- * through `fonts.families` and the `--font-*` tokens. The layer's
18
- * `modules:before` hook reads it, because @nuxt/fonts reads its families
19
- * when it installs, which can come before this module's setup.
17
+ * Keep the layer's composables and utils in the app's auto-import scan, and
18
+ * so in `#imports`. Defaults to false: their names, such as a one-argument
19
+ * `formatNumber`, would clash with a site's own helpers. A site imports
20
+ * them by path instead. Nuxt SEO's private `layers/brand` turns it on.
20
21
  */
21
- fonts?: boolean
22
+ autoImportUtils?: boolean
22
23
  /**
23
24
  * Hosts that count as this site. An absolute prose link to one of them gets
24
25
  * no external-link arrow. Defaults to none, so every absolute http(s) link
@@ -59,11 +60,46 @@ export default defineNuxtModule<DesignSystemOptions>({
59
60
  icon.clientBundle ??= {}
60
61
  icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
61
62
 
62
- const externalLinks = addTemplate({
63
- filename: 'design-system/external-links.css',
64
- getContents: () => externalLinkMarkerCss(options.internalLinkHosts ?? []),
65
- write: true,
63
+ // A plain file, not a Nuxt template. In dev, Nuxt links every
64
+ // `nuxt.options.css` entry the module graph has not loaded, matching by
65
+ // file path. The graph holds a template as a `virtual:nuxt:` id with no
66
+ // such path, so a template stylesheet was linked twice, the second copy
67
+ // after the site's own CSS. The CLI clears the build directory after
68
+ // modules load, so the file is written when the build starts.
69
+ const externalLinks = resolve(nuxt.options.buildDir, 'design-system/external-links.css')
70
+ nuxt.options.css.push(externalLinks)
71
+ nuxt.hook('build:before', () => {
72
+ mkdirSync(dirname(externalLinks), { recursive: true })
73
+ writeFileSync(externalLinks, externalLinkMarkerCss(options.internalLinkHosts ?? []))
66
74
  })
67
- nuxt.options.css.push(externalLinks.dst)
75
+
76
+ // The layer's templates pass `aria-*` attributes and a few listeners to
77
+ // components through $attrs, and `app/component-attributes.d.ts` types
78
+ // them. A consumer's tsconfig excludes node_modules, so the declaration
79
+ // never reached its program and a TypeScript 7 native typecheck failed
80
+ // inside the layer's templates. A reference ignores that exclude.
81
+ nuxt.hook('prepare:types', ({ references }) => {
82
+ references.push({ path: resolve(LAYER_ROOTS[0]!, 'app/component-attributes.d.ts') })
83
+ })
84
+
85
+ // Nuxt scans every layer's composables/ and utils/ for auto-imports, and a
86
+ // layer's `imports.scan: false` would merge into the app and stop its scan
87
+ // too. So the layer removes only its own directories here.
88
+ if (!options.autoImportUtils) {
89
+ nuxt.hook('imports:dirs', (dirs) => {
90
+ const kept = dirs.filter(dir => !isLayerPath(dir))
91
+ dirs.splice(0, dirs.length, ...kept)
92
+ })
93
+ }
68
94
  },
69
95
  })
96
+
97
+ const LAYER_ROOTS = [...new Set([resolve(import.meta.dirname, '..'), resolve(realpathSync(resolve(import.meta.dirname, '..')))])]
98
+
99
+ /** Whether a directory sits inside this layer, through a symlink or not. */
100
+ export function isLayerPath(dir: string): boolean {
101
+ const paths = [resolve(dir)]
102
+ if (existsSync(dir))
103
+ paths.push(resolve(realpathSync(dir)))
104
+ return paths.some(path => LAYER_ROOTS.some(root => path === root || path.startsWith(`${root}/`)))
105
+ }
package/css/global.css CHANGED
@@ -4,7 +4,7 @@
4
4
  /* Type families. A site sets its own in an unlayered `:root` rule; OG images
5
5
  read them from the Tailwind entry instead (README, Brand seams). `--font-title`
6
6
  carries page titles (`.text-title`, `font-title`) and defaults to the display
7
- family. The faces load only with `designSystem: { fonts: true }`. */
7
+ family. The layer registers no faces; a site loads its own (README). */
8
8
  @theme {
9
9
  --font-sans: 'Hubot Sans', sans-serif;
10
10
  --font-display: 'Hubot Sans', sans-serif;
@@ -575,7 +575,13 @@
575
575
  touch-action: manipulation;
576
576
  }
577
577
 
578
- .light {
578
+ /* Surface seam. The mode and dashboard blocks below sit in `:where()`, so
579
+ they carry no specificity: a site's plain `:root` rule sets any surface
580
+ token in both modes, and its `.dark` rule sets dark, whichever stylesheet
581
+ loads last. Nuxt UI's own defaults sit in `@layer theme` and still lose to
582
+ these. Source order decides between these blocks, so the dashboard blocks
583
+ stay after the mode blocks. */
584
+ :where(.light) {
579
585
  color-scheme: light;
580
586
  /* Bind surfaces to `--ui-color-neutral-*` (Nuxt UI's neutral alias → the
581
587
  hue-292 slate override) rather than stock `--color-neutral-*` (cold hue
@@ -609,7 +615,7 @@
609
615
  --nuxtseo-brand-tile-ink: oklch(98.4% 0.005 292);
610
616
  }
611
617
 
612
- .dark {
618
+ :where(.dark) {
613
619
  color-scheme: dark;
614
620
  /* Tier ladder in dark: 8.5 → 15 → 21 → 30. The old 7.5 → 13 gap read flat
615
621
  on large surfaces; the new deltas give nav / body / elevated cards a
@@ -1193,7 +1199,7 @@ textarea {
1193
1199
  /* Dashboard mauve palette: editorial monochrome tone reserved for /pro/dashboard.
1194
1200
  Marketing/docs pages stay on the warm-slate neutral (hue 285). A site keeps
1195
1201
  its own ramp here by setting `--ui-dashboard-neutral-<step>` (README). */
1196
- .dashboard-theme {
1202
+ :where(.dashboard-theme) {
1197
1203
  --ui-color-neutral-50: var(--ui-dashboard-neutral-50, var(--color-mauve-50, oklch(98.5% 0 0)));
1198
1204
  --ui-color-neutral-100: var(--ui-dashboard-neutral-100, var(--color-mauve-100, oklch(96.7% 0.001 286.375)));
1199
1205
  --ui-color-neutral-200: var(--ui-dashboard-neutral-200, var(--color-mauve-200, oklch(92% 0.004 286.32)));
@@ -1207,7 +1213,7 @@ textarea {
1207
1213
  --ui-color-neutral-950: var(--ui-dashboard-neutral-950, var(--color-mauve-950, oklch(14.1% 0.005 285.823)));
1208
1214
  }
1209
1215
 
1210
- .light.dashboard-theme {
1216
+ :where(.light.dashboard-theme) {
1211
1217
  background-color: var(--ui-bg);
1212
1218
  --ui-bg: #fff;
1213
1219
  --ui-bg-muted: var(--ui-color-neutral-50);
@@ -1226,7 +1232,7 @@ textarea {
1226
1232
  --ui-border-inverted: var(--ui-color-neutral-900);
1227
1233
  }
1228
1234
 
1229
- .dark.dashboard-theme {
1235
+ :where(.dark.dashboard-theme) {
1230
1236
  background-color: var(--ui-bg);
1231
1237
  --color-mauve-50: oklch(98.5% 0.002 326);
1232
1238
  --color-mauve-100: oklch(96.4% 0.005 325.6);
@@ -1356,7 +1362,7 @@ textarea {
1356
1362
  mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
1357
1363
  -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
1358
1364
  }
1359
- .ui-atmosphere[data-geometry="bloom"] {
1365
+ .ui-atmosphere[data-geometry="bloom"][data-bloom-mask] {
1360
1366
  mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
1361
1367
  -webkit-mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
1362
1368
  }
@@ -1366,7 +1372,8 @@ textarea {
1366
1372
  }
1367
1373
  /* veil = full scrim tint over a faded preview; no mask, softer by default */
1368
1374
 
1369
- /* grain — baked in, static (soft-light so it reads as tooth, not dirt) */
1375
+ /* grain — opt in through `grain` or `designSystem.atmosphere.grain`; static,
1376
+ soft-light so it reads as tooth, not dirt */
1370
1377
  .ui-atmosphere[data-grain]::after {
1371
1378
  content: "";
1372
1379
  position: absolute;
@@ -1477,10 +1484,40 @@ textarea {
1477
1484
  need primary/success/current keep overriding it (an unlayered rule would beat
1478
1485
  the utility and silently force muted). */
1479
1486
  @layer components {
1487
+ /* Type seam: a site sets `--ui-label-size`, `--ui-label-weight`,
1488
+ `--ui-label-case` and `--ui-label-tracking` at `:root` to retune every
1489
+ label at once (README, Brand seams). Unset, each keeps this spec. */
1480
1490
  .text-label {
1481
- @apply text-[10px] font-semibold uppercase tracking-wider;
1491
+ font-size: var(--ui-label-size, 10px);
1492
+ font-weight: var(--ui-label-weight, 600);
1493
+ text-transform: var(--ui-label-case, uppercase);
1494
+ letter-spacing: var(--ui-label-tracking, 0.05em);
1482
1495
  color: var(--ui-text-muted);
1483
1496
  }
1497
+
1498
+ /* Table type seam. Cells read `--ui-table-cell-size` and
1499
+ `--ui-table-cell-leading`; header labels read `--ui-table-head-size`, then
1500
+ the label size. Declared after `.text-label`, so a header carrying both
1501
+ takes the table size. Unset leading inherits, as the old rules did. */
1502
+ .ui-table-cell {
1503
+ font-size: var(--ui-table-cell-size, var(--text-xs));
1504
+ line-height: var(--ui-table-cell-leading, var(--text-xs--line-height));
1505
+ }
1506
+
1507
+ .ui-table-head {
1508
+ font-size: var(--ui-table-head-size, var(--ui-label-size, 10px));
1509
+ line-height: var(--ui-table-head-leading);
1510
+ }
1511
+
1512
+ /* `UiTrend` sizes `xs`; `2xs` sits below, unlayered like `.text-mini`. */
1513
+ .ui-trend-xs {
1514
+ font-size: var(--ui-trend-xs-size, var(--text-xs));
1515
+ line-height: var(--ui-trend-xs-leading, var(--text-xs--line-height));
1516
+ }
1517
+ }
1518
+
1519
+ .ui-trend-2xs {
1520
+ font-size: var(--ui-trend-2xs-size, 10px);
1484
1521
  }
1485
1522
 
1486
1523
  /* Micro label — the 9px tier below .text-label, for the tiniest tags (table
package/nuxt.config.ts CHANGED
@@ -1,9 +1,5 @@
1
- import type { NuxtOptions } from 'nuxt/schema'
2
- import type { DesignSystemOptions } from './build/design-system'
3
1
  import { defineNuxtConfig } from 'nuxt/config'
4
- import { useNuxt } from 'nuxt/kit'
5
2
  import { join } from 'pathe'
6
- import { withNuxtSeoFonts } from './build/fonts'
7
3
  import { UI_ICON_SLOTS } from './shared/icons/ui-slots'
8
4
 
9
5
  const currentDir = import.meta.dirname
@@ -84,28 +80,14 @@ export default defineNuxtConfig({
84
80
  ],
85
81
  },
86
82
 
87
- // The type stack is a brand seam. The layer self-hosts Nuxt SEO's Hubot Sans
88
- // and Fira Code in public/fonts/, and the `modules:before` hook below
89
- // registers them only for a site that sets `designSystem: { fonts: true }`.
83
+ // The type stack is a brand seam. The layer ships and registers no faces, so
84
+ // a site sets its own through `fonts.families` and the `--font-*` tokens.
90
85
  fonts: {
91
86
  // CSS variable discovery cannot add another face, so avoid parsing every
92
87
  // component style during each Vite build.
93
88
  processCSSVariables: false,
94
89
  },
95
90
 
96
- hooks: {
97
- // @nuxt/fonts reads its families when it installs, and an app that lists
98
- // @nuxt/fonts itself can install it before this layer's own module. This
99
- // hook runs before any module installs.
100
- 'modules:before': function () {
101
- const options = useNuxt().options as NuxtOptions & { designSystem?: DesignSystemOptions }
102
- // `false` turns @nuxt/fonts off, so there is nothing to register.
103
- if (options.fonts === false)
104
- return
105
- options.fonts.families = withNuxtSeoFonts(options.fonts.families, options.designSystem?.fonts)
106
- },
107
- },
108
-
109
91
  vite: {
110
92
  optimizeDeps: {
111
93
  // The layer's tables run @tanstack/vue-table 9, and @nuxt/ui's UTable