@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
package/README.md CHANGED
@@ -15,7 +15,7 @@ Design system layer: design language extended by every app. It owns UI primitive
15
15
 
16
16
  **Depends on:** `nuxtseo-shared` (brand marks) and the published Nuxt modules in `package.json`. No workspace package.
17
17
 
18
- **Consumed by:** `apps/site`, `apps/pro`, `apps/admin`, `apps/brand-kit`, and outside sites through the [npm](https://npmjs.com) package `@nuxtseo/design-system`.
18
+ **Consumed by:** `layers/brand`, which every Nuxt SEO app (`apps/site`, `apps/pro`, `apps/admin`, `apps/brand-kit`) extends in its place, and outside sites through the [npm](https://npmjs.com) package `@nuxtseo/design-system`.
19
19
 
20
20
  ## Rules
21
21
 
@@ -39,16 +39,21 @@ export default defineNuxtConfig({
39
39
  })
40
40
  ```
41
41
 
42
- - Peers: `nuxt` 4.6+, `vue` 3.5+, and `typescript` 5.8 to 6. The Vue SFC compiler resolves the layer's prop types through the TypeScript JS API, and TypeScript 7 has none.
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.
43
44
  - If [pnpm](https://pnpm.io) reports ignored build scripts, allow `esbuild` and `@parcel/watcher` under `allowBuilds` in `pnpm-workspace.yaml`.
44
- - Import components from `#components`. The layer turns auto-imports off.
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
+ - Write `app.config.ts` as a plain object. `defineAppConfig` is an auto-import, so the server bundle has no such function.
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
+ - 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.
45
50
 
46
51
  ### Overrides
47
52
 
48
53
  | What | How |
49
54
  | --- | --- |
50
55
  | Nuxt UI colours, slots, icons, toaster, favicon paths | `app.config.ts` in your app. It merges over the layer's `app/app.config.ts`. |
51
- | Tokens: colours, fonts, density, elevation | CSS custom properties in your own stylesheet, loaded after the layer's `css/global.css`. Override `--ui-*`, `--density-*`, and `--elevation-*`; never `--_depth-*`. |
56
+ | Tokens: colours, fonts, density, elevation | CSS custom properties in your own stylesheet, loaded after the layer's `css/global.css`. Override `--ui-*`, `--font-*`, `--density-*`, and `--elevation-*`; never `--_depth-*` or `--_viz-*`. |
52
57
  | One instance | Props and slots on the component. |
53
58
  | Icon set, chart colours, icon colour roles | The brand seams below. |
54
59
 
@@ -77,12 +82,103 @@ export default defineNuxtConfig({
77
82
  })
78
83
  ```
79
84
 
80
- **Chart colours.** Every chart identity slot in `dataVizColors.ts` reads a `--ui-viz-<slot>` custom property: `clicks`, `impressions`, `ctr`, `position`, `sessions`, `users`, `pageviews`, `lcp`, `inp`, `cls`, `current`, `comparison`, `device-desktop`, `device-mobile`, `device-tablet`, `brand`, `non-brand`, `rank-1` to `rank-10`, `resource-document`, `resource-script`, `resource-stylesheet`, `resource-image`, `resource-font`, `category-1` to `category-5`, and `neutral`. The SVG paint, the `bg-viz-*` and `text-viz-*` classes, and the `/15` fills all follow it. Text classes use the same colour, so pick one that passes AA as text. Unset, light mode reads a deeper default for any slot whose stock hue falls under 3:1 on white; dark mode keeps the stock hue.
85
+ **Favicon.** The layer's favicon plugin emits a theme-aware icon link and an Apple touch icon link from `favicon` in `app.config.ts`. Unset, it emits neither. Each link's MIME type follows the file extension, so an SVG declares `image/svg+xml`. A mode without its own path uses the other mode's path. The links carry `id: 'favicon'` and `id: 'apple-touch-icon'`.
86
+
87
+ ```ts
88
+ // app.config.ts
89
+ export default {
90
+ favicon: {
91
+ light: '/icon-light.svg',
92
+ dark: '/icon-dark.svg',
93
+ appleTouchLight: '/touch-icon.png',
94
+ appleTouchDark: '/touch-icon-dark.png',
95
+ },
96
+ }
97
+ ```
98
+
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.
100
+
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
+ }
122
+ ```
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
+
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.
127
+
128
+ ```ts
129
+ // nuxt.config.ts
130
+ export default defineNuxtConfig({
131
+ designSystem: { internalLinkHosts: ['example.com', 'www.example.com'] },
132
+ })
133
+ ```
134
+
135
+ **OG images.** [nuxt-og-image](https://nuxtseo.com/docs/og-image) compiles OG images against one Tailwind entry: the `#tailwindcss` alias, or else the first CSS entry that imports Tailwind, which is the layer's. Runtime `:root` overrides never reach it. To give OG images your theme, write an entry that imports the layer's stylesheet and restates your theme values. Then point the alias at it. Browsers never load this file.
136
+
137
+ ```css
138
+ /* app/assets/css/og-theme.css */
139
+ @import '@nuxtseo/design-system/css/global.css';
140
+
141
+ @theme {
142
+ --font-sans: 'DM Sans', system-ui, sans-serif;
143
+ --font-display: 'Poppins', system-ui, sans-serif;
144
+ --font-title: 'Poppins', system-ui, sans-serif;
145
+ }
146
+ ```
147
+
148
+ ```ts
149
+ // nuxt.config.ts
150
+ export default defineNuxtConfig({
151
+ alias: { '#tailwindcss': fileURLToPath(new URL('./app/assets/css/og-theme.css', import.meta.url)) },
152
+ })
153
+ ```
154
+
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.
156
+
157
+ | Property | Sets |
158
+ | --- | --- |
159
+ | `--ui-light-text`, `--ui-light-text-dimmed`, `--ui-light-text-highlighted` | `--ui-text`, `--ui-text-dimmed`, `--ui-text-highlighted` in light mode |
160
+ | `--ui-dashboard-neutral-50` to `--ui-dashboard-neutral-950` | The dashboard's neutral ramp, both modes |
161
+ | `--ui-dashboard-light-text-dimmed` | Dashboard `--ui-text-dimmed`, light mode |
162
+ | `--ui-dashboard-dark-text-dimmed`, `--ui-dashboard-dark-text-muted` | Dashboard `--ui-text-dimmed` and `--ui-text-muted`, dark mode |
163
+
164
+ ```css
165
+ :root {
166
+ --ui-light-text: var(--color-olive-800);
167
+ --ui-dashboard-neutral-50: var(--color-olive-50);
168
+ /* and each step to 950 */
169
+ }
170
+ ```
171
+
172
+ A `var()` inside a custom property resolves on the element that declares it. So `:root { --ui-dashboard-dark-text-muted: var(--ui-color-neutral-400) }` reads the marketing ramp's 400 step. Name a fixed colour or a `--color-*` step instead.
173
+
174
+ **Chart colours.** Every chart identity slot in `dataVizColors.ts` reads a `--ui-viz-<slot>` custom property: `clicks`, `impressions`, `ctr`, `position`, `sessions`, `users`, `pageviews`, `lcp`, `inp`, `cls`, `current`, `comparison`, `device-desktop`, `device-mobile`, `device-tablet`, `brand`, `non-brand`, `rank-1` to `rank-10`, `resource-document`, `resource-script`, `resource-stylesheet`, `resource-image`, `resource-font`, `category-1` to `category-5`, and `neutral`. The SVG paint, the `bg-viz-*` and `text-viz-*` classes, and the `/15` fills all follow it. Unset, light mode reads a deeper default for any slot whose stock hue falls under 3:1 on white; dark mode keeps the stock hue. Text classes use the mark colour unless `--ui-viz-<slot>-text` is set. If the mark colour fails AA as text, set it.
81
175
 
82
176
  ```css
83
177
  :root {
84
178
  --ui-viz-clicks: var(--ui-primary);
85
179
  --ui-viz-neutral: #94a3b8;
180
+ --ui-viz-comparison: #a3a292;
181
+ --ui-viz-comparison-text: #67713a;
86
182
  }
87
183
  ```
88
184
 
@@ -92,11 +188,61 @@ A palette `hex` field is a CSS colour, such as `var(--ui-viz-clicks, #3b82f6)`.
92
188
 
93
189
  ```ts
94
190
  // app.config.ts
95
- export default defineAppConfig({
191
+ export default {
96
192
  designSystem: {
97
193
  iconColors: { lime: { bg: 'bg-lime-500/10', text: 'text-lime-500' } },
98
194
  },
99
- })
195
+ }
196
+ ```
197
+
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>
100
211
  ```
101
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`.
247
+
102
248
  **Motion.** The layer provides motion-v's `domAnimation` features to the whole app, `error.vue` included. A site needs no `<LazyMotion>` wrapper.
package/app/app.config.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AppFaviconConfig } from './utils/app-favicon'
1
2
  import type { IconColorClasses } from './utils/icon-color'
2
3
  import { UI_ICON_SLOTS } from '../shared/icons/ui-slots'
3
4
 
@@ -13,16 +14,11 @@ export default {
13
14
  designSystem: {
14
15
  iconColors: {} as Record<string, IconColorClasses>,
15
16
  },
16
- // Theme-aware browser and installed-app brand marks. touch-icon(-dark).png are
17
- // deliberately not named apple-touch-icon*.png: nuxt-seo-utils auto-generates a
18
- // <link rel="apple-touch-icon"> for any public file matching that convention,
19
- // which duplicated the reactive one this plugin already emits.
20
- favicon: {
21
- light: '/favicon-light.png',
22
- dark: '/favicon-dark.png',
23
- appleTouchLight: '/touch-icon.png',
24
- appleTouchDark: '/touch-icon-dark.png',
25
- },
17
+ // Theme-aware favicon and touch icon paths, read by plugins/favicon.ts. The
18
+ // layer ships no brand images, so every path is unset here and the plugin
19
+ // emits nothing until an app names its own files. The MIME type follows
20
+ // each file's extension.
21
+ favicon: {} as AppFaviconConfig,
26
22
  toaster: {
27
23
  position: 'bottom-right' as const,
28
24
  // Collapsed peek-deck that fans out on hover (Sonner/Linear). Read by UiToaster.
@@ -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">
@@ -116,6 +116,7 @@ function errorText(value: unknown): string {
116
116
  type="search"
117
117
  class="w-full sm:w-56"
118
118
  :placeholder="searchPlaceholder"
119
+ icon="search"
119
120
  autocomplete="off"
120
121
  size="sm"
121
122
  :ui="{ base: 'min-h-11 transition-[width] duration-200 focus-within:w-72 sm:min-h-0' }"
@@ -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.