@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
@@ -10,10 +10,16 @@ const {
10
10
  numeric = false,
11
11
  rowHeader = false,
12
12
  visibleFrom,
13
+ colspan,
14
+ title,
13
15
  } = defineProps<UiTableCellProps & {
14
16
  size?: UiTableSize
15
17
  noPadding?: boolean
16
18
  rowHeader?: boolean
19
+ /** Columns this cell spans, e.g. a full-width note row. */
20
+ colspan?: number | string
21
+ /** Native tooltip, e.g. the full value behind a truncated cell. */
22
+ title?: string
17
23
  }>()
18
24
 
19
25
  const resolvedAlign = computed(() => numeric ? 'right' : align)
@@ -25,7 +31,9 @@ const padClass = computed(() => noPadding ? '' : 'px-3')
25
31
  <component
26
32
  :is="rowHeader ? 'th' : 'td'"
27
33
  :scope="rowHeader ? 'row' : undefined"
28
- class="text-xs font-normal text-default relative"
34
+ :colspan="colspan"
35
+ :title="title"
36
+ class="ui-table-cell font-normal text-default relative"
29
37
  :class="[
30
38
  uiTableCellSizeClass[size],
31
39
  padClass,
@@ -12,11 +12,17 @@ const {
12
12
  sortDirection = false,
13
13
  ariaLabel,
14
14
  visibleFrom,
15
+ colspan,
16
+ title,
15
17
  } = defineProps<UiTableCellProps & {
16
18
  sortable?: boolean
17
19
  noPadding?: boolean
18
20
  sortDirection?: 'asc' | 'desc' | false
19
21
  ariaLabel?: string
22
+ /** Columns this header spans, e.g. a group header over two metric columns. */
23
+ colspan?: number | string
24
+ /** Native tooltip, e.g. what a terse column label measures. */
25
+ title?: string
20
26
  }>()
21
27
 
22
28
  const emit = defineEmits<{ sort: [event: MouseEvent] }>()
@@ -39,14 +45,16 @@ const sortIconClass = computed(() => sortDirection === 'desc' ? 'rotate-180' : '
39
45
  <template>
40
46
  <th
41
47
  scope="col"
42
- class="text-label text-left whitespace-nowrap border-b border-default bg-default"
48
+ :colspan="colspan"
49
+ :title="title"
50
+ class="text-label ui-table-head text-left whitespace-nowrap border-b border-default bg-default"
43
51
  :class="[padClass, numeric ? 'text-right tabular-nums' : '', visibleFrom ? uiTableVisibleFromClass[visibleFrom] : '']"
44
52
  :aria-sort="sortable ? sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : 'none' : undefined"
45
53
  >
46
54
  <button
47
55
  v-if="sortable"
48
56
  type="button"
49
- class="flex min-h-11 w-full items-center gap-1 rounded px-3 py-2.5 text-label text-muted transition-colors hover:text-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0"
57
+ class="flex min-h-11 w-full items-center gap-1 rounded px-3 py-2.5 text-label ui-table-head text-muted transition-colors hover:text-default focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0"
50
58
  :class="justify"
51
59
  :aria-label="ariaLabel"
52
60
  @click="emit('sort', $event)"
@@ -37,7 +37,7 @@ const sortIcon = computed(() => {
37
37
  // Chevron rotates 180° between asc/desc — a single icon animates rather than swapping.
38
38
  const sortIconRotate = computed(() => sortDirection === 'desc' ? 'rotate-180' : '')
39
39
 
40
- const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
40
+ const buttonClass = 'flex min-h-11 items-center gap-1 w-full text-label ui-table-head cursor-pointer select-none transition-colors rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary sm:min-h-0'
41
41
  </script>
42
42
 
43
43
  <template>
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { readDesignSystemConfig } from '../../composables/design-system-config'
3
4
  // The greydient application primitive. Turns intent into a correctly-rendered
4
- // atmospheric layer — palette × geometry × intensity, plus grain — with the
5
+ // atmospheric layer — palette × geometry × intensity, plus opt-in grain — with the
5
6
  // mask/opacity/mode-handling all centralized in global.css (`.ui-atmosphere`).
6
7
  // Consumers place it as the first child of a `relative` container (content then
7
8
  // sits above it); for `sky` it spans the top of the nearest positioned ancestor.
@@ -18,7 +19,8 @@ const {
18
19
  palette: paletteProp,
19
20
  geometry: geometryProp,
20
21
  intensity: intensityProp,
21
- grain = true,
22
+ grain = undefined,
23
+ bloomMask = undefined,
22
24
  preset,
23
25
  } = defineProps<{
24
26
  /** Which greydient — the day-cycle of visibility states. */
@@ -27,8 +29,17 @@ const {
27
29
  geometry?: Geometry
28
30
  /** How present, per mode. subtle behind text; vivid is reserved for tiny identity surfaces. */
29
31
  intensity?: Intensity
30
- /** Fine grain for tooth. Default on. */
32
+ /**
33
+ * Fine grain for tooth. Unset, it follows `designSystem.atmosphere.grain` in
34
+ * app.config, which is off unless a site turns it on.
35
+ */
31
36
  grain?: boolean
37
+ /**
38
+ * Fade the `bloom` geometry to a centred glow. Unset, it follows
39
+ * `designSystem.atmosphere.bloomMask` in app.config, which is off unless a
40
+ * site turns it on. Other geometries keep their own masks.
41
+ */
42
+ bloomMask?: boolean
32
43
  /** Semantic shortcut — resolves palette+geometry+intensity from one intent. Explicit props win. */
33
44
  preset?: Preset
34
45
  }>()
@@ -50,6 +61,12 @@ const base = computed(() => (preset ? PRESETS[preset] : { palette: 'ash' as Pale
50
61
  const palette = computed(() => paletteProp ?? base.value.palette)
51
62
  const geometry = computed(() => geometryProp ?? base.value.geometry)
52
63
  const intensity = computed(() => intensityProp ?? base.value.intensity)
64
+
65
+ // Grain and the bloom mask are brand choices, so a site opts in once. Nuxt
66
+ // SEO's `layers/brand` app.config turns both on.
67
+ const siteAtmosphere = readDesignSystemConfig().atmosphere
68
+ const hasGrain = computed(() => grain ?? siteAtmosphere?.grain ?? false)
69
+ const hasBloomMask = computed(() => bloomMask ?? siteAtmosphere?.bloomMask ?? false)
53
70
  </script>
54
71
 
55
72
  <template>
@@ -58,7 +75,8 @@ const intensity = computed(() => intensityProp ?? base.value.intensity)
58
75
  class="ui-atmosphere"
59
76
  :data-geometry="geometry"
60
77
  :data-intensity="intensity"
61
- :data-grain="grain ? '' : undefined"
78
+ :data-grain="hasGrain ? '' : undefined"
79
+ :data-bloom-mask="geometry === 'bloom' && hasBloomMask ? '' : undefined"
62
80
  :style="{ '--_atmo-greydient': `var(--greydient-${palette})` }"
63
81
  />
64
82
  </template>
@@ -1,13 +1,20 @@
1
1
  <script setup lang="ts">
2
- import type { ConfirmTone } from '../../composables/useConfirm'
2
+ import type { VNodeChild } from 'vue'
3
+ import type { ConfirmResult, ConfirmTone } from '../../composables/useConfirm'
4
+ import { ref } from 'vue'
3
5
  import { UiButton, UiIcon, UModal } from '#components'
4
6
 
5
7
  /**
6
8
  * UiConfirmDialog: the one "are you sure" for an action that is hard to undo.
7
9
  *
8
10
  * Open it through `useConfirm()`, which resolves `true` for the confirm button
9
- * and `false` for Cancel, Escape, or a click outside. The dialog never runs the
10
- * action itself, so the caller keeps its own pending state and error handling.
11
+ * and `false` for Cancel, Escape, or a click outside. With an `action`, the
12
+ * confirm button runs it and shows its loading state until it settles, and
13
+ * the dialog cannot be dismissed meanwhile. Then the dialog closes; a failed
14
+ * action rejects the `useConfirm()` promise with its error.
15
+ *
16
+ * The `#body` slot, or the `body` render function from `useConfirm()`, holds
17
+ * rich content under the description, such as a bold Site name.
11
18
  *
12
19
  * Focus lands on Cancel for a `danger` tone, so Enter on a stray keypress
13
20
  * cancels. A `default` tone focuses the confirm button.
@@ -18,6 +25,8 @@ const {
18
25
  confirmLabel,
19
26
  cancelLabel = 'Cancel',
20
27
  tone = 'danger',
28
+ body,
29
+ action,
21
30
  } = defineProps<{
22
31
  /** A question that names the action and its object: "Disconnect Google?" */
23
32
  title: string
@@ -27,13 +36,43 @@ const {
27
36
  confirmLabel: string
28
37
  cancelLabel?: string
29
38
  tone?: ConfirmTone
39
+ /** Rich content under the description. The `#body` slot wins over it. */
40
+ body?: () => VNodeChild
41
+ /** Runs on confirm while the confirm button shows its loading state. */
42
+ action?: () => Promise<unknown>
30
43
  }>()
31
44
 
32
45
  const emit = defineEmits<{
33
- /** `true` confirms. Nuxt UI's overlay resolves the open promise with it. */
34
- close: [confirmed: boolean]
46
+ /** Nuxt UI's overlay resolves the open promise with this result. */
47
+ close: [result: ConfirmResult]
48
+ }>()
49
+
50
+ defineSlots<{
51
+ body?: () => unknown
35
52
  }>()
36
53
 
54
+ const pending = ref(false)
55
+
56
+ async function confirm(): Promise<void> {
57
+ if (!action) {
58
+ emit('close', { _tag: 'Confirmed' })
59
+ return
60
+ }
61
+ pending.value = true
62
+ // Promise.resolve().then() turns a synchronous throw into a rejection, so
63
+ // the dialog always leaves its loading state.
64
+ const result = await Promise.resolve().then(action).then(
65
+ (): ConfirmResult => ({ _tag: 'Confirmed' }),
66
+ (error: unknown): ConfirmResult => ({ _tag: 'ActionFailed', error }),
67
+ )
68
+ pending.value = false
69
+ emit('close', result)
70
+ }
71
+
72
+ function Body() {
73
+ return body?.()
74
+ }
75
+
37
76
  // Reka focuses the first focusable element unless this handler moves focus.
38
77
  // Pick the button by tone instead of trusting the DOM order.
39
78
  function onOpenAutoFocus(event: Event): void {
@@ -51,6 +90,7 @@ function onOpenAutoFocus(event: Event): void {
51
90
  :title="title"
52
91
  :description="description"
53
92
  :close="false"
93
+ :dismissible="!pending"
54
94
  :content="{ onOpenAutoFocus }"
55
95
  :ui="{ content: 'sm:max-w-md', description: 'text-pretty' }"
56
96
  >
@@ -62,6 +102,11 @@ function onOpenAutoFocus(event: Event): void {
62
102
  {{ title }}
63
103
  </span>
64
104
  </template>
105
+ <template v-if="$slots.body || body" #body>
106
+ <slot name="body">
107
+ <Body />
108
+ </slot>
109
+ </template>
65
110
  <template #footer>
66
111
  <div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
67
112
  <UiButton
@@ -69,14 +114,16 @@ function onOpenAutoFocus(event: Event): void {
69
114
  class="min-h-11 sm:min-h-0"
70
115
  data-confirm-focus="cancel"
71
116
  :label="cancelLabel"
72
- @click="emit('close', false)"
117
+ :disabled="pending"
118
+ @click="emit('close', { _tag: 'Cancelled' })"
73
119
  />
74
120
  <UiButton
75
121
  :purpose="tone === 'danger' ? 'danger' : 'cta'"
76
122
  class="min-h-11 sm:min-h-0"
77
123
  data-confirm-focus="confirm"
78
124
  :label="confirmLabel"
79
- @click="emit('close', true)"
125
+ :loading="pending"
126
+ @click="confirm"
80
127
  />
81
128
  </div>
82
129
  </template>
@@ -21,6 +21,11 @@ import { ONBOARDING_INTENTS } from './onboarding-intents'
21
21
  // The goals list and the copy name the product, so a site that extends this
22
22
  // layer passes its own `intents` and any `copy` keys it rewords. The option
23
23
  // ids are shared and stored, so they never change.
24
+ //
25
+ // A site with its own look draws the controls through two scoped slots and
26
+ // keeps this component's answer logic: `#option` draws one single-choice
27
+ // option and `#intents` draws the goals and the free-text box. Saving stays
28
+ // with the site: this layer never fetches.
24
29
  const {
25
30
  intents = ONBOARDING_INTENTS,
26
31
  copy: copyOverrides = {},
@@ -28,6 +33,19 @@ const {
28
33
  intents?: readonly OnboardingIntent[]
29
34
  copy?: Partial<OnboardingAnswersCopy>
30
35
  }>()
36
+ defineSlots<{
37
+ /** One single-choice option. `pick` selects it, or clears it when it is already picked. */
38
+ option?: (props: { question: SingleKey, option: { id: string, label: string }, selected: boolean, pick: () => void }) => unknown
39
+ /** The goals and the free-text box, under the goals question. */
40
+ intents?: (props: {
41
+ intents: readonly OnboardingIntent[]
42
+ selected: readonly string[]
43
+ toggle: (intent: string) => void
44
+ other: string
45
+ setOther: (other: string) => void
46
+ }) => unknown
47
+ }>()
48
+
31
49
  const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
32
50
  const answers = defineModel<OnboardingAnswersDraft>({ required: true })
33
51
 
@@ -69,16 +87,24 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
69
87
  {{ item.question }}
70
88
  </legend>
71
89
  <div class="flex flex-wrap gap-2">
72
- <button
73
- v-for="option in item.options"
74
- :key="option.id"
75
- type="button"
76
- :aria-pressed="answers[item.key] === option.id"
77
- :class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
78
- @click="pick(item.key, option.id)"
79
- >
80
- {{ option.label }}
81
- </button>
90
+ <template v-for="option in item.options" :key="option.id">
91
+ <slot
92
+ name="option"
93
+ :question="item.key"
94
+ :option="option"
95
+ :selected="answers[item.key] === option.id"
96
+ :pick="() => pick(item.key, option.id)"
97
+ >
98
+ <button
99
+ type="button"
100
+ :aria-pressed="answers[item.key] === option.id"
101
+ :class="[chipClass, answers[item.key] === option.id ? selectedClass : restClass]"
102
+ @click="pick(item.key, option.id)"
103
+ >
104
+ {{ option.label }}
105
+ </button>
106
+ </slot>
107
+ </template>
82
108
  </div>
83
109
  </fieldset>
84
110
 
@@ -86,42 +112,51 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
86
112
  <legend class="text-sm font-medium text-highlighted">
87
113
  {{ copy.intentsQuestion }}
88
114
  </legend>
89
- <p class="text-xs text-muted">
115
+ <p v-if="copy.intentsHint" class="text-xs text-muted">
90
116
  {{ copy.intentsHint }}
91
117
  </p>
92
- <div class="flex flex-wrap gap-2">
93
- <button
94
- v-for="intent in intents"
95
- :key="intent.id"
96
- type="button"
97
- :aria-pressed="answers.intents.includes(intent.id)"
98
- :class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
99
- @click="toggleIntent(intent.id)"
100
- >
101
- <UiIcon
102
- :name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
103
- class="size-4 shrink-0"
104
- :class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
105
- aria-hidden="true"
118
+ <slot
119
+ name="intents"
120
+ :intents="intents"
121
+ :selected="answers.intents"
122
+ :toggle="toggleIntent"
123
+ :other="answers.other ?? ''"
124
+ :set-other="setOther"
125
+ >
126
+ <div class="flex flex-wrap gap-2">
127
+ <button
128
+ v-for="intent in intents"
129
+ :key="intent.id"
130
+ type="button"
131
+ :aria-pressed="answers.intents.includes(intent.id)"
132
+ :class="[chipClass, answers.intents.includes(intent.id) ? selectedClass : restClass]"
133
+ @click="toggleIntent(intent.id)"
134
+ >
135
+ <UiIcon
136
+ :name="answers.intents.includes(intent.id) ? 'check' : intent.icon"
137
+ class="size-4 shrink-0"
138
+ :class="answers.intents.includes(intent.id) ? 'text-primary' : 'text-muted'"
139
+ aria-hidden="true"
140
+ />
141
+ {{ intent.label }}
142
+ </button>
143
+ </div>
144
+ <div class="space-y-1.5 pt-1">
145
+ <label :for="otherId" class="text-xs text-muted">
146
+ {{ copy.otherLabel }}
147
+ </label>
148
+ <UTextarea
149
+ :id="otherId"
150
+ :model-value="answers.other"
151
+ :maxlength="ONBOARDING_OTHER_MAX_LENGTH"
152
+ :rows="2"
153
+ autoresize
154
+ :placeholder="copy.otherPlaceholder"
155
+ class="w-full"
156
+ @update:model-value="setOther(String($event ?? ''))"
106
157
  />
107
- {{ intent.label }}
108
- </button>
109
- </div>
110
- <div class="space-y-1.5 pt-1">
111
- <label :for="otherId" class="text-xs text-muted">
112
- {{ copy.otherLabel }}
113
- </label>
114
- <UTextarea
115
- :id="otherId"
116
- :model-value="answers.other"
117
- :maxlength="ONBOARDING_OTHER_MAX_LENGTH"
118
- :rows="2"
119
- autoresize
120
- :placeholder="copy.otherPlaceholder"
121
- class="w-full"
122
- @update:model-value="setOther(String($event ?? ''))"
123
- />
124
- </div>
158
+ </div>
159
+ </slot>
125
160
  </fieldset>
126
161
  </div>
127
162
  </template>
@@ -10,6 +10,7 @@ defineOptions({ inheritAttrs: false })
10
10
  const {
11
11
  type = 'text',
12
12
  lines = 1,
13
+ count = 1,
13
14
  index = 0,
14
15
  base = 160,
15
16
  range = 80,
@@ -20,10 +21,16 @@ const {
20
21
  * decide the box — use it whenever the placeholder must match a known layout
21
22
  * (a `w-full` row, a fixed-width label). The self-sizing types write an inline
22
23
  * `width`/`height`, which silently beats any `w-*`/`h-*` class passed in.
24
+ *
25
+ * The presets draw a whole page-level loading shape: `table-row` (a name
26
+ * column and three value columns), `stat` (stat blocks in a grid), `chart`
27
+ * (16 bars), and `card` (two-column cards). `count` sets how many.
23
28
  */
24
- type?: 'text' | 'bar' | 'circle' | 'block'
29
+ type?: 'text' | 'bar' | 'circle' | 'block' | 'table-row' | 'stat' | 'chart' | 'card'
25
30
  /** Number of skeleton lines (text type only) */
26
31
  lines?: number
32
+ /** Number of rows, stat blocks, or cards (presets only). The chart always draws 16 bars. */
33
+ count?: number
27
34
  /** Seed index for deterministic width/height variation */
28
35
  index?: number
29
36
  /** Base size in px — text: width, bar: height percent, circle: diameter */
@@ -94,6 +101,48 @@ function barHeight(i: number): string {
94
101
  class="ui-skeleton rounded-full"
95
102
  :style="{ width: `${base}px`, height: `${base}px` }"
96
103
  />
104
+
105
+ <!-- Preset: table rows, a name column then three value columns -->
106
+ <div v-else-if="type === 'table-row'" aria-hidden="true" v-bind="attrs" class="space-y-2">
107
+ <div
108
+ v-for="i in count"
109
+ :key="i"
110
+ class="grid grid-cols-[minmax(120px,2fr)_repeat(3,minmax(56px,1fr))] items-center gap-3"
111
+ >
112
+ <div class="ui-skeleton rounded h-3 max-w-full" :style="{ width: textWidth(i) }" />
113
+ <div class="ui-skeleton rounded h-3 w-full" />
114
+ <div class="ui-skeleton rounded h-3 w-2/3 justify-self-end" />
115
+ <div class="ui-skeleton rounded h-3 w-1/2 justify-self-end" />
116
+ </div>
117
+ </div>
118
+
119
+ <!-- Preset: stat blocks -->
120
+ <div v-else-if="type === 'stat'" aria-hidden="true" v-bind="attrs" class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
121
+ <div v-for="i in count" :key="i" class="space-y-2 rounded-lg border border-default bg-muted p-3">
122
+ <div class="ui-skeleton rounded h-3 w-20" />
123
+ <div class="ui-skeleton rounded h-6 max-w-full" :style="{ width: textWidth(i + 10) }" />
124
+ <div class="ui-skeleton rounded h-3 w-24" />
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Preset: a bar chart -->
129
+ <div v-else-if="type === 'chart'" aria-hidden="true" v-bind="attrs" class="flex h-56 items-end gap-2">
130
+ <div
131
+ v-for="i in 16"
132
+ :key="i"
133
+ class="ui-skeleton rounded flex-1"
134
+ :style="{ height: barHeight(i) }"
135
+ />
136
+ </div>
137
+
138
+ <!-- Preset: cards -->
139
+ <div v-else-if="type === 'card'" aria-hidden="true" v-bind="attrs" class="grid gap-3 sm:grid-cols-2">
140
+ <div v-for="i in count" :key="i" class="space-y-3 rounded-lg border border-default bg-muted p-4">
141
+ <div class="ui-skeleton rounded h-4 max-w-full" :style="{ width: textWidth(i) }" />
142
+ <div class="ui-skeleton rounded h-3 w-full" />
143
+ <div class="ui-skeleton rounded h-3 w-2/3" />
144
+ </div>
145
+ </div>
97
146
  </template>
98
147
 
99
148
  <!--
@@ -87,6 +87,12 @@ function captureChildTrigger(event: Event): void {
87
87
  childTriggerElement.value = event.currentTarget
88
88
  }
89
89
 
90
+ // One object, not two `@` listeners: gscdump.com's TypeScript 7.1 native
91
+ // typecheck rejected `@pointermove` and `@focus` on reka's `TooltipTrigger`.
92
+ // Listeners bound through `v-bind` fall through to the rendered root the same
93
+ // way.
94
+ const childTriggerListeners = { onPointermove: captureChildTrigger, onFocus: captureChildTrigger }
95
+
90
96
  // Span trigger. Reka listens for `focus` on the trigger itself, and focus does
91
97
  // not bubble, so a focusable child (a button, a link, a tabindex cell) never
92
98
  // opened the tooltip and the description landed on the span. Listen for
@@ -231,8 +237,7 @@ export const sizes = {
231
237
  v-if="$slots.default && triggerAs === 'child'"
232
238
  :reference="childTriggerElement"
233
239
  as-child
234
- @pointermove="captureChildTrigger"
235
- @focus="captureChildTrigger"
240
+ v-bind="childTriggerListeners"
236
241
  >
237
242
  <slot />
238
243
  </TooltipTrigger>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import { UiIcon } from '#components'
4
+ import { readDesignSystemConfig } from '../../composables/design-system-config'
4
5
  import { clamp } from '../../utils/number'
5
6
 
6
7
  const {
@@ -10,7 +11,7 @@ const {
10
11
  format = 'number',
11
12
  showSign = false,
12
13
  inverted = false,
13
- colored = false,
14
+ colored = undefined,
14
15
  isNew = false,
15
16
  isLost = false,
16
17
  autoLost = true,
@@ -29,7 +30,8 @@ const {
29
30
  /**
30
31
  * Trend color budget (DESIGN.md Composition): colored success/error trends
31
32
  * belong to the hero zone only. Default renders neutral (sign + value);
32
- * hero components (UiMetricsRow, UiMetricStat, hero cards) opt in.
33
+ * hero components (UiMetricsRow, UiMetricStat, hero cards) opt in. A site
34
+ * that colours every trend sets `designSystem.trend.colored` in app.config.
33
35
  */
34
36
  colored?: boolean
35
37
  /** Show a "NEW" badge instead of trend value */
@@ -51,15 +53,21 @@ const {
51
53
  precision?: number | 'auto'
52
54
  }>()
53
55
 
56
+ // `2xs` and `xs` read the type seam (`--ui-trend-2xs-size`, `--ui-trend-xs-size`).
54
57
  const sizes = {
55
- '2xs': 'text-mini',
56
- 'xs': 'text-xs',
58
+ '2xs': 'ui-trend-2xs',
59
+ 'xs': 'ui-trend-xs',
57
60
  'sm': 'text-sm',
58
61
  'md': 'text-base',
59
62
  'lg': 'text-lg',
60
63
  'xl': 'text-xl',
61
64
  } as const
62
65
 
66
+ // An unset prop takes the site's default. Nuxt SEO's app.config sets none, so
67
+ // its trends stay neutral (DESIGN.md, trend colour budget).
68
+ const siteColored = readDesignSystemConfig().trend?.colored ?? false
69
+ const isColored = computed(() => colored ?? siteColored)
70
+
63
71
  const numericValue = computed(() => typeof value === 'string' ? Number.parseFloat(value) : value)
64
72
 
65
73
  // Auto-detect "lost" state: percent format and value <= -100 means went to zero
@@ -78,7 +86,7 @@ const clampsValue = computed(() => shouldClamp && format === 'percent')
78
86
 
79
87
  const trendColor = computed(() => {
80
88
  const t = trend.value
81
- if (t === 0 || !colored)
89
+ if (t === 0 || !isColored.value)
82
90
  return 'text-muted'
83
91
  const positive = inverted ? t === -1 : t === 1
84
92
  return positive ? 'text-success' : 'text-error'
@@ -144,11 +152,11 @@ const classes = computed(() => {
144
152
 
145
153
  <template>
146
154
  <!-- NEW badge mode -->
147
- <span v-if="isNew" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-success/10 text-success" :class="sizes[size as keyof typeof sizes]">
155
+ <span v-if="isNew" data-ui="UiTrend" class="inline-flex items-center font-medium [text-transform:var(--ui-trend-badge-case,uppercase)] px-1.5 py-0.5 rounded bg-success/10 text-success" :class="sizes[size as keyof typeof sizes]">
148
156
  New
149
157
  </span>
150
158
  <!-- LOST badge mode (explicit or auto-detected from -100% change) -->
151
- <span v-else-if="isLost || detectedLost" data-ui="UiTrend" class="inline-flex items-center font-medium uppercase px-1.5 py-0.5 rounded bg-error/10 text-error" :class="sizes[size as keyof typeof sizes]">
159
+ <span v-else-if="isLost || detectedLost" data-ui="UiTrend" class="inline-flex items-center font-medium [text-transform:var(--ui-trend-badge-case,uppercase)] px-1.5 py-0.5 rounded bg-error/10 text-error" :class="sizes[size as keyof typeof sizes]">
152
160
  Lost
153
161
  </span>
154
162
  <!-- Zero trend — show dash -->
@@ -158,7 +166,7 @@ const classes = computed(() => {
158
166
  v-else-if="isClamped"
159
167
  data-ui="UiTrend"
160
168
  class="inline-flex items-center whitespace-nowrap leading-none font-semibold tracking-tight px-1.5 py-0.5 ml-1 rounded cursor-default"
161
- :class="[classes, colored ? (trend === 1 ? 'bg-success/10' : 'bg-error/10') : 'bg-muted']"
169
+ :class="[classes, isColored ? (trend === 1 ? 'bg-success/10' : 'bg-error/10') : 'bg-muted']"
162
170
  :title="fullValue"
163
171
  >
164
172
  {{ trend === 1 ? '10x+' : 'oof' }}
@@ -0,0 +1,29 @@
1
+ import type { FailureCopy, FailureCopyOverrides } from '../shared/failure-copy'
2
+ import { tryUseNuxtApp, useAppConfig } from 'nuxt/app'
3
+ import { resolveFailureCopy } from '../shared/failure-copy'
4
+
5
+ /**
6
+ * The runtime brand seams a site sets under `designSystem` in app.config.
7
+ * README (Brand seams) documents each key. Unset keys keep Nuxt SEO's
8
+ * behaviour, so the layer's own app.config sets none of them.
9
+ */
10
+ export interface DesignSystemAppConfig {
11
+ /** `UiTrend`'s `colored` when a caller passes none. Default false. */
12
+ readonly trend?: { readonly colored?: boolean }
13
+ /** `UiAtmosphere`'s grain and `bloom` mask when a caller passes none. Default off. */
14
+ readonly atmosphere?: { readonly grain?: boolean, readonly bloomMask?: boolean }
15
+ /** Rewords the failure copy (`FailureCopyOverrides`). */
16
+ readonly failureCopy?: FailureCopyOverrides
17
+ }
18
+
19
+ /** The site's `designSystem` app.config, or none outside a Nuxt app. Call it in setup. */
20
+ export function readDesignSystemConfig(): DesignSystemAppConfig {
21
+ if (!tryUseNuxtApp())
22
+ return {}
23
+ return (useAppConfig().designSystem ?? {}) as DesignSystemAppConfig
24
+ }
25
+
26
+ /** The failure copy with the site's overrides applied. Call it in setup. */
27
+ export function readFailureCopy(): FailureCopy {
28
+ return resolveFailureCopy(readDesignSystemConfig().failureCopy)
29
+ }