@nuxtseo/design-system 0.1.1 → 0.1.2

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 +79 -13
  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
package/README.md CHANGED
@@ -39,13 +39,14 @@ export default defineNuxtConfig({
39
39
  })
40
40
  ```
41
41
 
42
- - Peers: `nuxt` 4.6+, `vue` 3.5+, `typescript` 5.8 to 6, `@nuxt/ui` 4.11+, `motion-v` 2.4+, `tailwindcss` 4.3+, and `zod` 4. The Vue SFC compiler resolves the layer's prop types through the TypeScript JS API, and TypeScript 7 has none.
43
- - The layer shares your copy of each peer, so the app runs one Nuxt UI, one Tailwind, one motion-v context, and one zod. pnpm and npm install a missing peer for you. You need no `overrides` for the layer.
42
+ - Peers: `nuxt` 4.6+, `vue` 3.5+, `typescript` 5.8 to 6, `@nuxt/ui` 4.11+, `@nuxt/scripts` 1.3+ or 2, `@vueuse/core` 14 or 15, `motion-v` 2.4+, `tailwindcss` 4.3+, and `zod` 4. The Vue SFC compiler resolves the layer's prop types through the TypeScript JS API, and TypeScript 7 has none. A TypeScript 7 native typecheck of your app still passes over the layer's components.
43
+ - The layer shares your copy of each peer, so the app runs one Nuxt UI, one `@nuxt/scripts`, one [VueUse](https://vueuse.org), one Tailwind, one motion-v context, and one zod. pnpm and npm install a missing peer for you. You need no `overrides` for the layer.
44
44
  - If [pnpm](https://pnpm.io) reports ignored build scripts, allow `esbuild` and `@parcel/watcher` under `allowBuilds` in `pnpm-workspace.yaml`.
45
- - Import components from `#components`. The layer turns auto-imports off, in Nitro too.
45
+ - Import components from `#components`. The layer turns auto-imports off, in Nitro too. If your app turns them back on, the layer's composables and utils stay out of its scan, so a name such as `formatNumber` never shadows your own. Import them by path.
46
46
  - Write `app.config.ts` as a plain object. `defineAppConfig` is an auto-import, so the server bundle has no such function.
47
47
  - Import other modules by their path: `@nuxtseo/design-system/app/composables/formatting`. A directory with an `index.ts` imports by its name: `@nuxtseo/design-system/shared/icons`, `@nuxtseo/design-system/shared/contracts`, and `@nuxtseo/design-system/types`.
48
- - The layer ships no favicon and loads no fonts. Set both through the brand seams below.
48
+ - To wrap a component, import its props from a `.ts` module, never from the `.vue` file: the SFC compiler cannot resolve a type inside a package `.vue` file. `UiTableProps` and `UiTableSlots` live in `@nuxtseo/design-system/app/shared/table`, `UiStatProps` in `app/shared/stat`, and `UiToastProps` and `UiToasterProps` in `app/shared/toast`.
49
+ - The layer ships no favicon and no font files. Set both through the brand seams below.
49
50
 
50
51
  ### Overrides
51
52
 
@@ -95,16 +96,33 @@ export default {
95
96
  }
96
97
  ```
97
98
 
98
- **Type.** Set `--font-sans`, `--font-display`, `--font-title`, and `--font-mono` in your stylesheet, and register the faces with `fonts.families`. `--font-title` carries page titles (`.text-title` and the `font-title` utility). Its default is the display family. The layer self-hosts Nuxt SEO's Hubot Sans and Fira Code but registers neither by default, so no page preloads them. To use them, opt in:
99
+ **Type.** Set `--font-sans`, `--font-display`, `--font-title`, and `--font-mono` in your stylesheet, and register the faces with `fonts.families`. `--font-title` carries page titles (`.text-title` and the `font-title` utility). Its default is the display family. The layer ships no font files and registers no faces.
99
100
 
100
- ```ts
101
- // nuxt.config.ts
102
- export default defineNuxtConfig({
103
- extends: ['@nuxtseo/design-system'],
104
- designSystem: { fonts: true },
105
- })
101
+ **Type scale.** Set these properties at `:root` to resize or recase the dense tiers. Unset, each keeps the value in the second column.
102
+
103
+ | Property | Default | Sets |
104
+ | --- | --- | --- |
105
+ | `--ui-label-size`, `--ui-label-weight`, `--ui-label-case`, `--ui-label-tracking` | `10px`, `600`, `uppercase`, `0.05em` | `.text-label` and `UiMetricLabel` |
106
+ | `--ui-table-cell-size`, `--ui-table-cell-leading` | `--text-xs` and its leading | Body cells of `UiTable` and `UiTableTd` |
107
+ | `--ui-table-head-size`, `--ui-table-head-leading` | `--ui-label-size`, inherited | Header labels of `UiTable` and `UiTableTh` |
108
+ | `--ui-trend-2xs-size`, `--ui-trend-xs-size`, `--ui-trend-xs-leading` | `10px`, `--text-xs` and its leading | `UiTrend` sizes `2xs` and `xs` |
109
+ | `--ui-trend-badge-case` | `uppercase` | `UiTrend` New and Lost |
110
+
111
+ ```css
112
+ :root {
113
+ --ui-label-size: 0.75rem;
114
+ --ui-label-weight: 500;
115
+ --ui-label-case: none;
116
+ --ui-label-tracking: normal;
117
+ --ui-table-cell-size: var(--text-sm);
118
+ --ui-table-cell-leading: var(--text-sm--line-height);
119
+ --ui-table-head-size: var(--text-sm);
120
+ --ui-trend-badge-case: none;
121
+ }
106
122
  ```
107
123
 
124
+ **Surfaces.** The layer sets every `--ui-bg*`, `--ui-text*` and `--ui-border*` token on `.light`, `.dark` and `.dashboard-theme` inside `:where()`, so those rules carry no specificity. Set a surface token in a plain `:root` rule and it wins in both modes; set it in a `.dark` rule to change dark mode only. Source order does not matter.
125
+
108
126
  **Own-site links.** An absolute HTTP or HTTPS prose link gets an external-link arrow. List the hosts that count as your site, and links to them get none. Unset, the layer lists no host.
109
127
 
110
128
  ```ts
@@ -134,7 +152,7 @@ export default defineNuxtConfig({
134
152
  })
135
153
  ```
136
154
 
137
- **Text and dashboard neutrals.** Three light mode text tokens sit on Nuxt SEO's violet hue, off the neutral ramp. The dashboard (`.dashboard-theme`) swaps the neutral ramp to mauve. Set these properties to re-hue them; never override the selectors. Unset, each keeps Nuxt SEO's value.
155
+ **Text and dashboard neutrals.** Three light mode text tokens sit on Nuxt SEO's violet hue, off the neutral ramp. The dashboard (`.dashboard-theme`) swaps the neutral ramp to mauve. Set these properties to re-hue them. Unset, each keeps Nuxt SEO's value.
138
156
 
139
157
  | Property | Sets |
140
158
  | --- | --- |
@@ -177,6 +195,54 @@ export default {
177
195
  }
178
196
  ```
179
197
 
180
- **Onboarding copy.** `UiOnboardingAnswers` takes an `intents` prop for the goal list and a `copy` prop for its strings: `roleQuestion`, `siteCountQuestion`, `seoExperienceQuestion`, `intentsQuestion`, `intentsHint`, `otherLabel`, and `otherPlaceholder`. Pass any of them. Unset keys keep the strings in `ONBOARDING_ANSWERS_COPY`. Onboarding Answers store the option ids, so the ids never change.
198
+ **Onboarding copy.** `UiOnboardingAnswers` takes an `intents` prop for the goal list and a `copy` prop for its strings: `roleQuestion`, `siteCountQuestion`, `seoExperienceQuestion`, `intentsQuestion`, `intentsHint`, `otherLabel`, and `otherPlaceholder`. Pass any of them. Unset keys keep the strings in `ONBOARDING_ANSWERS_COPY`. An empty `intentsHint` hides the hint. Onboarding Answers store the option ids, so the ids never change.
199
+
200
+ **Onboarding controls.** Two scoped slots draw `UiOnboardingAnswers` in your own look and keep its answer logic. `#option="{ question, option, selected, pick }"` draws one single-choice option; `pick()` selects it, or clears it when it is already picked. `#intents="{ intents, selected, toggle, other, setOther }"` draws the goals and the free-text box. Your heading, Skip, and Save sit around the component, and saving stays with your app: the layer never fetches.
201
+
202
+ ```vue
203
+ <UiOnboardingAnswers v-model="answers" :copy="{ intentsHint: '' }">
204
+ <template #option="{ option, selected, pick }">
205
+ <UButton :label="option.label" :variant="selected ? 'soft' : 'outline'" :aria-pressed="selected" @click="pick" />
206
+ </template>
207
+ <template #intents="{ selected, toggle, other, setOther }">
208
+ <MyGoalsGrid :selected="selected" :other="other" @toggle="toggle" @update:other="setOther" />
209
+ </template>
210
+ </UiOnboardingAnswers>
211
+ ```
212
+
213
+ **Trend colour.** `UiTrend` draws a change in neutral text unless a caller passes `colored`, because Nuxt SEO spends colour on hero trends only. A site that colours every trend sets the default once:
214
+
215
+ ```ts
216
+ // app.config.ts
217
+ export default {
218
+ designSystem: { trend: { colored: true } },
219
+ }
220
+ ```
221
+
222
+ **Atmosphere.** `UiAtmosphere` draws no grain and leaves the `bloom` geometry unmasked unless asked. Pass `grain` or `bloom-mask` on one instance, or turn either on for the whole site with `designSystem: { atmosphere: { grain: true, bloomMask: true } }` in `app.config.ts`. Nuxt SEO's own brand layer turns both on.
223
+
224
+ **Failure copy.** `UiWidgetState`, `UiDataList` and `UiErrorState` state a failed read in copy the layer owns (COPY.md, Shared primitive assets). Reword any of it in `app.config.ts`. A failure keeps its retry behaviour; only the words change. Unset keys keep the defaults in `FAILURE_COPY` (`app/shared/failure-copy.ts`).
225
+
226
+ ```ts
227
+ // app.config.ts
228
+ export default {
229
+ designSystem: {
230
+ failureCopy: {
231
+ failures: { server: { title: 'We could not load this data' } },
232
+ retryLabel: 'Try again',
233
+ },
234
+ },
235
+ }
236
+ ```
237
+
238
+ ## Component notes
239
+
240
+ - **`UiTable` cell slots.** A `#<column id>-cell="{ row, cell, value }"` slot draws that column's cells and wins over the column's `cell` renderer. Columns with no slot keep their renderer. `#empty` replaces the zero-rows line.
241
+ - **`UiTableTd` and `UiTableTh`** take `colspan` and `title`.
242
+ - **`UiConfirmDialog`** takes a `#body` slot; `useConfirm()` takes a `body` render function for the same place. Pass `action` to `useConfirm()` and the confirm button runs it with a loading state, the dialog cannot be dismissed meanwhile, and then it closes. A rejected action rejects the `useConfirm()` promise.
243
+ - **`UiAlert`** takes `:animated="false"` to skip its entrance.
244
+ - **`UiSectionHeader size="lg"`** is the long-form page tier: a 24 to 30px heading over a top rule.
245
+ - **`UiSkeleton`** draws whole loading shapes with `type="table-row"`, `"stat"`, `"chart"` or `"card"`; `count` sets how many.
246
+ - **`UiTopEntityStackChart`** has no `yMax` or `bucketTotal`. `scale="share"` fills each bar to 100%, and the tooltip states each bucket's total through `format`.
181
247
 
182
248
  **Motion.** The layer provides motion-v's `domAnimation` features to the whole app, `error.vue` included. A site needs no `<LazyMotion>` wrapper.
@@ -8,7 +8,7 @@ import { semanticColors } from '../../composables/semanticColors'
8
8
  import { useEntranceMotion } from '../../composables/useEntranceMotion'
9
9
  import { entrancePresets } from '../../shared/motion'
10
10
 
11
- const { status = 'info', icon, title } = defineProps<{
11
+ const { status = 'info', icon, title, animated = true } = defineProps<{
12
12
  /** Semantic status — drives the accent icon + bar + corner bloom (surface
13
13
  * stays neutral for every status). Shared name across the status family
14
14
  * (UiChip, UiStatusBadge). */
@@ -21,6 +21,8 @@ const { status = 'info', icon, title } = defineProps<{
21
21
  description?: string
22
22
  /** Show close/dismiss button */
23
23
  dismissible?: boolean
24
+ /** Fade the alert in when the client mounts it. Set false for an alert that must not move, such as one inside a dialog. */
25
+ animated?: boolean
24
26
  }>()
25
27
 
26
28
  const emit = defineEmits<{
@@ -51,7 +53,7 @@ const resolvedIcon = computed(() => icon || defaultIcons[status])
51
53
  const isUrgent = computed(() => status === 'error' || status === 'warning')
52
54
  const dismissLabel = computed(() => title ? `Dismiss alert: ${title}` : 'Dismiss alert')
53
55
 
54
- const entrance = useEntranceMotion(entrancePresets.fadeUp)
56
+ const entrance = useEntranceMotion(entrancePresets.fadeUp, () => animated)
55
57
  </script>
56
58
 
57
59
  <template>
@@ -2,6 +2,7 @@
2
2
  import type { ErrorStateSource } from '../../shared/error-state'
3
3
  import { computed } from 'vue'
4
4
  import { UiAlert, UiButton } from '#components'
5
+ import { readFailureCopy } from '../../composables/design-system-config'
5
6
  import { resolveErrorState } from '../../shared/error-state'
6
7
 
7
8
  const { error } = defineProps<{
@@ -16,7 +17,7 @@ const { error } = defineProps<{
16
17
  * the caught message, which quotes the request URL.
17
18
  */
18
19
  error: ErrorStateSource | null
19
- /** Optional retry handler. When set (and no #action slot), renders a "Try again" button. */
20
+ /** Optional retry handler. When set (and no #action slot), renders a retry button ("Try again" unless app.config rewords it). */
20
21
  onRetry?: () => void
21
22
  }>()
22
23
 
@@ -27,7 +28,8 @@ defineSlots<{
27
28
 
28
29
  // `Date.now()` is read once per resolve, the same as the old inline reset
29
30
  // maths. The reset hint counts down only when something else re-renders.
30
- const view = computed(() => error ? resolveErrorState(error, Date.now()) : null)
31
+ const copy = readFailureCopy()
32
+ const view = computed(() => error ? resolveErrorState(error, Date.now(), copy) : null)
31
33
  </script>
32
34
 
33
35
  <template>
@@ -53,7 +55,7 @@ const view = computed(() => error ? resolveErrorState(error, Date.now()) : null)
53
55
  purpose="secondary"
54
56
  size="xs"
55
57
  icon="refresh"
56
- label="Try again"
58
+ :label="copy.errorStateRetryLabel"
57
59
  class="mt-2"
58
60
  @click="onRetry()"
59
61
  />
@@ -19,6 +19,7 @@ const {
19
19
  chat,
20
20
  mcp,
21
21
  schema,
22
+ size = 'sm',
22
23
  } = defineProps<{
23
24
  title: string
24
25
  description?: string
@@ -35,14 +36,23 @@ const {
35
36
  chat?: EjectChat
36
37
  mcp?: EjectMcp
37
38
  schema?: EjectLink
39
+ /**
40
+ * `sm` is the dashboard tier: the `.text-heading` section heading. `lg` is
41
+ * the long-form page tier: a 24 to 30px heading over a top rule, so page
42
+ * sections keep their hierarchy above an on-page FAQ heading.
43
+ */
44
+ size?: 'sm' | 'lg'
38
45
  }>()
39
46
 
40
47
  const slots = useSlots()
41
48
  const hasEject = computed(() => !!(chat || mcp || schema))
49
+ const headingClass = computed(() => size === 'lg'
50
+ ? 'font-sans text-2xl font-semibold tracking-[-0.025em] text-default md:text-3xl'
51
+ : 'text-heading text-default')
42
52
  </script>
43
53
 
44
54
  <template>
45
- <div class="flex items-center justify-between gap-3 mb-3">
55
+ <div class="flex items-center justify-between gap-3" :class="size === 'lg' ? 'mb-6 border-t border-accented pt-5' : 'mb-3'">
46
56
  <div class="flex items-center gap-2 min-w-0">
47
57
  <!-- A section that reads through one Source passes its mark here, in
48
58
  place of the decorative icon. -->
@@ -50,7 +60,7 @@ const hasEject = computed(() => !!(chat || mcp || schema))
50
60
  <UiNavIcon v-if="icon" :icon="icon" />
51
61
  </slot>
52
62
  <div class="min-w-0">
53
- <h2 class="text-heading text-default flex items-center gap-2">
63
+ <h2 class="flex items-center gap-2" :class="headingClass">
54
64
  <slot name="title">
55
65
  {{ title }}
56
66
  </slot>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
- import type { Component } from 'vue'
3
2
  import type { SemanticStatus } from '../../composables/semanticColors'
3
+ import type { ToastAction, UiToastProps } from '../../shared/toast'
4
4
  import { useElementSize } from '@vueuse/core'
5
5
  import { computed, useTemplateRef, watch } from 'vue'
6
6
  import { UiButton, UiIcon } from '#components'
@@ -13,42 +13,6 @@ import { semanticColors } from '../../composables/semanticColors'
13
13
  // renders this inside a `<motion.li>`. Rendered exclusively from the shared
14
14
  // useToast() queue, so every existing `toast.add(...)` call site keeps working.
15
15
 
16
- export interface ToastAction {
17
- label: string
18
- icon?: string
19
- to?: string
20
- onClick?: (e: MouseEvent) => void
21
- /** Visual emphasis. `primary` = filled, `neutral` = quiet. */
22
- color?: 'primary' | 'neutral'
23
- }
24
-
25
- export interface UiToastProps {
26
- /** Heading text (string or a render component). */
27
- title?: string | Component
28
- /** Body text (string or a render component). */
29
- description?: string | Component
30
- /** Semantic status — drives accent + default icon. Wins over `color`. */
31
- status?: SemanticStatus
32
- /** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
33
- color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
34
- /** Icon override. Defaults to the status icon. */
35
- icon?: string
36
- /** Inline action buttons. */
37
- actions?: ToastAction[]
38
- /**
39
- * Bug-report handler. When set, failure toasts (error/warning) that carry no
40
- * action of their own get a "Report bug" button. Wired by the app (the
41
- * design system must not reach for the feedback drawer itself).
42
- */
43
- reportBug?: (toast: { title?: string, description?: string }) => void
44
- /** Show the close button. */
45
- close?: boolean
46
- /** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
47
- progress?: number | false
48
- /** Collapsed back card — fade content so the deck reads as peeking cards. */
49
- peek?: boolean
50
- }
51
-
52
16
  const {
53
17
  title,
54
18
  description,
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { UiToastProps } from './UiToast.vue'
2
+ import type { UiToasterProps, UiToastProps } from '../../shared/toast'
3
3
  import { useMounted, useRafFn } from '@vueuse/core'
4
4
  import { AnimatePresence, motion, useReducedMotion } from 'motion-v'
5
5
  import { computed, reactive, ref, watch } from 'vue'
@@ -15,16 +15,6 @@ import UiToast from './UiToast.vue'
15
15
  // Nuxt UI's shared useToast() queue, so every existing `toast.add(...)` keeps
16
16
  // working unchanged. (Interaction model: brand-kit /toasts → "Fire 5 at once".)
17
17
 
18
- export interface UiToasterProps {
19
- position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
20
- /** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
21
- max?: number
22
- /** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
23
- duration?: number
24
- /** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
25
- reportBug?: UiToastProps['reportBug']
26
- }
27
-
28
18
  const {
29
19
  position = 'bottom-right',
30
20
  max = 3,
@@ -4,7 +4,8 @@ import type { ViewState } from '../../shared/view-state'
4
4
  import type { WidgetFailure } from '../../shared/widget-failure'
5
5
  import { computed, onMounted, ref } from 'vue'
6
6
  import { UiButton, UiEmptyState, UiIcon, UiSkeleton } from '#components'
7
- import { toWidgetFailure, WIDGET_FAILURE_COPY } from '../../shared/widget-failure'
7
+ import { readFailureCopy } from '../../composables/design-system-config'
8
+ import { toWidgetFailure } from '../../shared/widget-failure'
8
9
 
9
10
  const {
10
11
  state,
@@ -88,8 +89,9 @@ const failure = computed(() => toWidgetFailure(state?._tag === 'error' ? state.e
88
89
  const data = computed(() => state?._tag === 'data' ? state.data : undefined)
89
90
  // A refresh failed while an earlier answer is still on screen. The content
90
91
  // stays and a notice says the refresh failed.
91
- const refreshFailure = computed(() => state?._tag === 'data' && state.refreshError ? WIDGET_FAILURE_COPY[toWidgetFailure(state.refreshError)._tag] : null)
92
- const failureCopy = computed(() => WIDGET_FAILURE_COPY[failure.value._tag])
92
+ const copy = readFailureCopy()
93
+ const refreshFailure = computed(() => state?._tag === 'data' && state.refreshError ? copy.failures[toWidgetFailure(state.refreshError)._tag] : null)
94
+ const failureCopy = computed(() => copy.failures[failure.value._tag])
93
95
  </script>
94
96
 
95
97
  <template>
@@ -114,7 +116,7 @@ const failureCopy = computed(() => WIDGET_FAILURE_COPY[failure.value._tag])
114
116
  {{ failureCopy.body }}
115
117
  </p>
116
118
  <UiButton v-if="failureCopy.retryable" size="xs" purpose="secondary" icon="refresh" @click="emit('retry')">
117
- Retry
119
+ {{ copy.retryLabel }}
118
120
  </UiButton>
119
121
  </div>
120
122
  </slot>
@@ -133,7 +135,7 @@ const failureCopy = computed(() => WIDGET_FAILURE_COPY[failure.value._tag])
133
135
  <span class="font-medium text-default">{{ refreshFailure.title }}.</span> {{ refreshFailure.body }}
134
136
  </p>
135
137
  <UiButton v-if="refreshFailure.retryable" purpose="secondary" size="xs" icon="refresh" @click="emit('retry')">
136
- Retry
138
+ {{ copy.retryLabel }}
137
139
  </UiButton>
138
140
  </div>
139
141
  <slot :data="data" />
@@ -3,6 +3,7 @@ import type { UiIcon as UiIconName } from '../../shared/ui-icons'
3
3
  import type { ViewState } from '../../shared/view-state'
4
4
  import { computed, onMounted, ref } from 'vue'
5
5
  import { NuxtLink, UiButton, UiHelpLabel, UiIcon, UiNavIcon, UiSkeleton } from '#components'
6
+ import { readFailureCopy } from '../../composables/design-system-config'
6
7
  import { widgetFailureCopy } from '../../shared/widget-failure'
7
8
  import { getIconColor } from '../../utils/icon-color'
8
9
 
@@ -113,11 +114,12 @@ const view = computed<ViewState<readonly T[]>>(() => {
113
114
  })
114
115
  const isLoading = computed(() => view.value._tag === 'loading')
115
116
  const rows = computed<readonly T[]>(() => view.value._tag === 'data' ? view.value.data : [])
117
+ const copy = readFailureCopy()
116
118
  const failure = computed(() => {
117
119
  if (view.value._tag === 'error')
118
- return widgetFailureCopy(view.value.error)
120
+ return widgetFailureCopy(view.value.error, copy.failures)
119
121
  if (view.value._tag === 'data' && view.value.refreshError != null)
120
- return widgetFailureCopy(view.value.refreshError)
122
+ return widgetFailureCopy(view.value.refreshError, copy.failures)
121
123
  return null
122
124
  })
123
125
 
@@ -207,7 +209,7 @@ function itemKey(item: T, index: number): string | number {
207
209
  {{ failure.body }}
208
210
  </p>
209
211
  <UiButton v-if="onRetry && failure.retryable" purpose="secondary" size="xs" icon="refresh" class="mt-2" @click="onRetry()">
210
- Retry
212
+ {{ copy.retryLabel }}
211
213
  </UiButton>
212
214
  </div>
213
215
  </div>
@@ -238,7 +240,7 @@ function itemKey(item: T, index: number): string | number {
238
240
  <span class="font-medium text-default">{{ failure.title }}.</span> {{ failure.body }}
239
241
  </p>
240
242
  <UiButton v-if="onRetry && failure.retryable" purpose="secondary" size="xs" icon="refresh" @click="onRetry()">
241
- Retry
243
+ {{ copy.retryLabel }}
242
244
  </UiButton>
243
245
  </div>
244
246
  <div v-if="view._tag === 'data'" class="flex-1 p-1 space-y-0.5">
@@ -8,9 +8,11 @@ const { size = 'xs', tone = 'dimmed' } = defineProps<{
8
8
 
9
9
  // xs = the dense .text-label tier (10px); sm = a 12px label for popover/list
10
10
  // section headings. (Both were 10px before — the sm tier was a dead no-op.)
11
+ // Both follow the label case and tracking seam (`--ui-label-case`,
12
+ // `--ui-label-tracking`), so a sentence-case site sets them once.
11
13
  const sizeClass = {
12
14
  xs: 'text-label',
13
- sm: 'text-xs uppercase tracking-wider font-semibold',
15
+ sm: 'text-xs font-semibold [text-transform:var(--ui-label-case,uppercase)] [letter-spacing:var(--ui-label-tracking,0.05em)]',
14
16
  } as const
15
17
 
16
18
  const toneClass = {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { UiIcon as UiIconName } from '../../shared/ui-icons'
2
+ import type { UiStatProps } from '../../shared/stat'
3
3
  import type { SlotTextOptions } from '../../utils/slot-text'
4
4
  import { useElementHover } from '@vueuse/core'
5
5
  import { computed, onMounted, ref, useSlots } from 'vue'
@@ -41,65 +41,6 @@ const {
41
41
  // NuxtLink auto-registers globally; resolve by name so we don't depend on
42
42
  // the `#components` virtual (which doesn't narrow nicely in a layer).
43
43
 
44
- type Datum = Record<string, number | string>
45
-
46
- export interface UiStatProps {
47
- // link
48
- to?: string
49
-
50
- // label
51
- icon?: UiIconName
52
- title?: string
53
- tooltip?: string
54
- tooltipDescription?: string
55
-
56
- // value
57
- value?: string | number | null
58
- suffix?: string
59
- valueClass?: string
60
- format?: (n: number) => string
61
-
62
- // trend
63
- trend?: number | null
64
- /**
65
- * Formats the trend magnitude. The value `format` never applies: the trend is
66
- * a percent or a delta, so a currency formatter printed "+$12.5%".
67
- */
68
- trendFormat?: (n: number) => string
69
- trendSuffix?: string
70
- trendLabel?: string
71
- invertTrend?: boolean
72
- /**
73
- * Trend color budget (DESIGN.md Composition): colored success/error trends
74
- * belong to the hero zone only. Default renders neutral; `UiStats
75
- * variant="cards"` (the hero pattern) opts its stats in automatically.
76
- */
77
- trendColored?: boolean
78
-
79
- // sparkline
80
- sparkline?: Datum[]
81
-
82
- // sizing
83
- size?: 'sm' | 'md' | 'lg'
84
- /** Keep report-visible labels and deltas at the 14px readable tier. */
85
- readableLabels?: boolean
86
-
87
- // state
88
- loading?: boolean
89
-
90
- /**
91
- * Rolls compact metric values when they change. Defaults on for numeric-ish
92
- * values; pass false for dense/realtime surfaces or object options to tune.
93
- */
94
- animatedValue?: boolean | SlotTextOptions
95
-
96
- // threshold alerting
97
- status?: 'crisis' | 'warning' | 'good'
98
-
99
- // render the value area inside a card, keeping the header outside
100
- card?: boolean
101
- }
102
-
103
44
  const hydrated = ref(false)
104
45
  onMounted(() => {
105
46
  hydrated.value = true
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { UiStatProps } from './UiStat.vue'
2
+ import type { UiStatProps } from '../../shared/stat'
3
3
  // Port of nuxtseo/core UiStats. Swaps tailwind-merge for a plain
4
4
  // filter+join — the component doesn't generate conflicting Tailwind classes,
5
5
  // and consumer `class` attrs cascade normally via the class attribute merge.
@@ -8,12 +8,12 @@ import type {
8
8
  RowSelectionState,
9
9
  SortingState,
10
10
  } from '@tanstack/vue-table'
11
- import type { UiTableColumn, UiTableFeatures, UiTableRowId, UiTableSize } from '../../shared/table'
11
+ import type { UiTableColumn, UiTableFeatures, UiTableProps, UiTableSlots } from '../../shared/table'
12
12
  import { FlexRender, functionalUpdate, useTable } from '@tanstack/vue-table'
13
13
  import { useIntersectionObserver } from '@vueuse/core'
14
- import { computed, ref, toRef, useId, useSlots, useTemplateRef, watch } from 'vue'
14
+ import { computed, ref, toRef, useId, useTemplateRef, watch } from 'vue'
15
15
  import { UiButton, UiSkeleton, UiTableExport, UiTableFrame, UiTableHeaderCell, UPagination } from '#components'
16
- import { resolveUiTableRowId, uiTableCellSizeClass, uiTableFeatures, uiTableSkeletonSizeClass, uiTableVisibleFromClass } from '../../shared/table'
16
+ import { resolveUiTableRowId, uiTableCellSizeClass, uiTableCellSlotName, uiTableFeatures, uiTableSkeletonSizeClass, uiTableVisibleFromClass } from '../../shared/table'
17
17
  import { uiTableExport } from '../../shared/table-export'
18
18
 
19
19
  const {
@@ -51,6 +51,8 @@ const emit = defineEmits<{
51
51
  'sortColumn': [column: string]
52
52
  }>()
53
53
 
54
+ const slots = defineSlots<UiTableSlots<T>>()
55
+
54
56
  const selectedModel = defineModel<RowSelectionState>('selected')
55
57
  const pageModel = defineModel<number>('page', { default: 1 })
56
58
  const sortingModel = defineModel<SortingState>('sorting', { default: () => [] })
@@ -170,8 +172,6 @@ function onRowKeydown(e: KeyboardEvent, row: T, tanstackRow: Row<UiTableFeatures
170
172
  }
171
173
  }
172
174
 
173
- const slots = useSlots()
174
-
175
175
  // Optional trailing actions column (row buttons/menus). Rendered outside the
176
176
  // TanStack column model so callers express rich actions as a slot, not a cell
177
177
  // render fn. `colSpan` accounts for it in the empty/skeleton rows.
@@ -283,49 +283,6 @@ if (import.meta.dev) {
283
283
  }
284
284
  </script>
285
285
 
286
- <script lang="ts">
287
- export interface UiTableProps<T extends object> {
288
- data: T[]
289
- columns: UiTableColumn<T>[]
290
- selected?: RowSelectionState
291
- controlledSelection?: boolean
292
- rowHover?: boolean
293
- rowClickable?: boolean
294
- enableSorting?: boolean
295
- /** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
296
- manualSorting?: boolean
297
- pageSize?: number
298
- ignoreHeader?: boolean
299
- size?: UiTableSize
300
- loading?: boolean
301
- loadingRows?: number
302
- rowId?: UiTableRowId<T>
303
- manualPagination?: boolean
304
- disablePagination?: boolean
305
- total?: number
306
- /** Accessible name for the table. Rendered as a visually-hidden <caption>. */
307
- label: string
308
- /** Draw the canonical rounded table surface. */
309
- bordered?: boolean
310
- /** Pagination density. Compact renders previous, page position, and next only. */
311
- paginationPreset?: 'default' | 'compact'
312
- /** Fill the caller's available height and keep pagination pinned to the bottom. */
313
- fill?: boolean
314
- /** Extra classes applied per data row — e.g. a selection highlight. */
315
- rowClass?: (row: T) => string
316
- /** Row id presented as the active item in a click-through table. */
317
- activeRowId?: string
318
- /** Row id to open when the table first receives deep-linked evidence. */
319
- initialExpandedRowId?: string
320
- /**
321
- * Show Export CSV and Export JSON, saving to this file name without the
322
- * extension. The export holds every filtered and sorted row, not one page.
323
- * Ignored with `manualPagination`: mount `UiTableExport` with every row.
324
- */
325
- exportFilename?: string
326
- }
327
- </script>
328
-
329
286
  <template>
330
287
  <div class="w-full" :class="fill && 'flex h-full min-h-0 flex-col'">
331
288
  <UiTableExport
@@ -353,7 +310,7 @@ export interface UiTableProps<T extends object> {
353
310
  <th
354
311
  v-for="header in headerGroup.headers"
355
312
  :key="header.id"
356
- class="text-label text-left whitespace-nowrap border-b border-default bg-default"
313
+ class="text-label ui-table-head text-left whitespace-nowrap border-b border-default bg-default"
357
314
  :class="[
358
315
  uiColumnDef(header.column).noPadding ? '' : header.column.getCanSort() ? 'px-2' : 'px-3',
359
316
  getTextAlignClass(uiColumnDef(header.column).align, uiColumnDef(header.column).numeric),
@@ -397,7 +354,7 @@ export interface UiTableProps<T extends object> {
397
354
  v-for="cell in row.getVisibleCells()"
398
355
  :key="cell.id"
399
356
  :scope="uiColumnDef(cell.column).rowHeader ? 'row' : undefined"
400
- class="text-xs font-normal text-default relative"
357
+ class="ui-table-cell font-normal text-default relative"
401
358
  :class="[
402
359
  uiTableCellSizeClass[size],
403
360
  uiColumnDef(cell.column).noPadding ? '' : cell.column.getCanSort() ? 'px-2' : 'px-3',
@@ -422,6 +379,13 @@ export interface UiTableProps<T extends object> {
422
379
  :base="60"
423
380
  :range="50"
424
381
  />
382
+ <slot
383
+ v-else-if="slots[uiTableCellSlotName(cell.column.id)]"
384
+ :name="uiTableCellSlotName(cell.column.id)"
385
+ :row="row"
386
+ :cell="cell"
387
+ :value="cell.getValue()"
388
+ />
425
389
  <FlexRender v-else :cell="cell" />
426
390
  </component>
427
391
  <td v-if="hasActions" class="text-right whitespace-nowrap px-3" :class="uiTableCellSizeClass[size]" @click.stop>
@@ -444,7 +408,7 @@ export interface UiTableProps<T extends object> {
444
408
  <td
445
409
  v-for="(col, j) in columns"
446
410
  :key="j"
447
- class="text-xs px-3"
411
+ class="ui-table-cell px-3"
448
412
  :class="[
449
413
  uiTableCellSizeClass[size],
450
414
  getTextAlignClass(col.align, col.numeric),
@@ -458,7 +422,7 @@ export interface UiTableProps<T extends object> {
458
422
 
459
423
  <tr v-else>
460
424
  <td :colspan="colSpan" class="h-24 text-center" role="status">
461
- <slot name="empty-component">
425
+ <slot name="empty">
462
426
  <span class="text-xs font-mono text-dimmed">0 rows · adjust filters</span>
463
427
  </slot>
464
428
  </td>
@@ -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,