@nuxtseo/design-system 0.1.0 → 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 (54) hide show
  1. package/README.md +153 -7
  2. package/app/app.config.ts +6 -10
  3. package/app/components/container/UiAlert.vue +4 -2
  4. package/app/components/container/UiErrorState.vue +5 -3
  5. package/app/components/container/UiSectionHeader.vue +12 -2
  6. package/app/components/container/UiToast.vue +1 -37
  7. package/app/components/container/UiToaster.vue +1 -11
  8. package/app/components/container/UiWidgetState.vue +7 -5
  9. package/app/components/data/UiDataList.vue +6 -4
  10. package/app/components/data/UiDataTableSection.vue +1 -0
  11. package/app/components/data/UiMetricLabel.vue +3 -1
  12. package/app/components/data/UiStat.vue +1 -60
  13. package/app/components/data/UiStats.vue +1 -1
  14. package/app/components/data/UiTable.vue +16 -52
  15. package/app/components/data/UiTableTd.vue +9 -1
  16. package/app/components/data/UiTableTh.vue +10 -2
  17. package/app/components/data/cells/UiTableHeaderCell.vue +1 -1
  18. package/app/components/element/UiAtmosphere.vue +22 -4
  19. package/app/components/element/UiConfirmDialog.vue +54 -7
  20. package/app/components/element/UiOnboardingAnswers.vue +95 -58
  21. package/app/components/element/UiSelect.vue +15 -2
  22. package/app/components/element/UiSkeleton.vue +50 -1
  23. package/app/components/element/UiTooltip.vue +7 -2
  24. package/app/components/element/UiTrend.vue +16 -8
  25. package/app/components/element/onboarding-answers.ts +25 -0
  26. package/app/composables/design-system-config.ts +29 -0
  27. package/app/composables/useConfirm.ts +31 -3
  28. package/app/plugins/favicon.ts +7 -21
  29. package/app/shared/error-state.ts +13 -12
  30. package/app/shared/failure-copy.ts +53 -0
  31. package/app/shared/stat.ts +65 -0
  32. package/app/shared/table.ts +74 -1
  33. package/app/shared/toast.ts +52 -0
  34. package/app/shared/widget-failure.ts +3 -2
  35. package/app/utils/app-favicon.ts +64 -0
  36. package/build/design-system.ts +58 -0
  37. package/build/external-links.ts +38 -0
  38. package/css/global.css +112 -71
  39. package/nuxt.config.ts +19 -26
  40. package/package.json +22 -14
  41. package/public/brand-icon-dark.png +0 -0
  42. package/public/brand-icon-light.png +0 -0
  43. package/public/favicon-dark.png +0 -0
  44. package/public/favicon-light.png +0 -0
  45. package/public/favicon.ico +0 -0
  46. package/public/fonts/README.md +0 -31
  47. package/public/fonts/fira-code-OFL.txt +0 -93
  48. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  49. package/public/fonts/hubot-sans-OFL.txt +0 -93
  50. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  51. package/public/icon-dark.png +0 -0
  52. package/public/icon.png +0 -0
  53. package/public/touch-icon-dark.png +0 -0
  54. package/public/touch-icon.png +0 -0
@@ -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' }}
@@ -44,6 +44,31 @@ export const ONBOARDING_QUESTIONS = {
44
44
  intents: 'What do you want to do with Nuxt SEO Pro?',
45
45
  } as const
46
46
 
47
+ /**
48
+ * Every string `UiOnboardingAnswers` renders besides the option labels. COPY.md
49
+ * (Onboarding About you) owns the defaults. A site that extends the layer
50
+ * passes its own through the component's `copy` prop, one key or all of them.
51
+ */
52
+ export interface OnboardingAnswersCopy {
53
+ roleQuestion: string
54
+ siteCountQuestion: string
55
+ seoExperienceQuestion: string
56
+ intentsQuestion: string
57
+ intentsHint: string
58
+ otherLabel: string
59
+ otherPlaceholder: string
60
+ }
61
+
62
+ export const ONBOARDING_ANSWERS_COPY: Readonly<OnboardingAnswersCopy> = {
63
+ roleQuestion: ONBOARDING_QUESTIONS.role,
64
+ siteCountQuestion: ONBOARDING_QUESTIONS.siteCount,
65
+ seoExperienceQuestion: ONBOARDING_QUESTIONS.seoExperience,
66
+ intentsQuestion: ONBOARDING_QUESTIONS.intents,
67
+ intentsHint: 'Pick any that fit.',
68
+ otherLabel: 'Anything else? Optional.',
69
+ otherPlaceholder: 'Tell us what you\'re after',
70
+ }
71
+
47
72
  /** Label lookup for stored ids. An unknown id renders as itself. */
48
73
  export function onboardingAnswerLabel(options: readonly { id: string, label: string }[], id: string): string {
49
74
  return options.find(option => option.id === id)?.label ?? id
@@ -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
+ }
@@ -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,29 +1,15 @@
1
1
  import { defineNuxtPlugin, useAppConfig, useHead } from 'nuxt/app'
2
2
  import { useColorMode } from '#imports'
3
+ import { appFaviconLinks } from '../utils/app-favicon'
3
4
 
5
+ // Emits the favicon and touch icon links that app.config's `favicon` names,
6
+ // and swaps them with the colour mode. The layer ships no brand images, so an
7
+ // app that sets no `favicon` gets no links from here.
4
8
  export default defineNuxtPlugin(() => {
5
9
  const { favicon } = useAppConfig()
6
10
  const colorMode = useColorMode()
7
11
 
8
- useHead(() => {
9
- const dark = colorMode.value === 'dark'
10
-
11
- return {
12
- link: [
13
- {
14
- id: 'favicon',
15
- rel: 'icon',
16
- type: 'image/png',
17
- sizes: '32x32',
18
- href: dark ? favicon.dark : favicon.light,
19
- },
20
- {
21
- id: 'apple-touch-icon',
22
- rel: 'apple-touch-icon',
23
- sizes: '180x180',
24
- href: dark ? favicon.appleTouchDark : favicon.appleTouchLight,
25
- },
26
- ],
27
- }
28
- })
12
+ useHead(() => ({
13
+ link: appFaviconLinks(favicon, colorMode.value === 'dark'),
14
+ }))
29
15
  })
@@ -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
  }