@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
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The app's own theme-aware favicon and touch icon, set under `favicon` in
3
+ * app.config. Every path is optional: the layer ships no brand images, so an
4
+ * unset path emits no link. A mode with no path of its own uses the other
5
+ * mode's path.
6
+ */
7
+ export interface AppFaviconConfig {
8
+ light?: string
9
+ dark?: string
10
+ appleTouchLight?: string
11
+ appleTouchDark?: string
12
+ }
13
+
14
+ export type AppFaviconLink
15
+ = { id: 'favicon', rel: 'icon', href: string, type?: string, sizes?: string }
16
+ | { id: 'apple-touch-icon', rel: 'apple-touch-icon', href: string, sizes: string }
17
+
18
+ const MIME_BY_EXTENSION: Readonly<Record<string, string>> = {
19
+ avif: 'image/avif',
20
+ gif: 'image/gif',
21
+ ico: 'image/x-icon',
22
+ jpeg: 'image/jpeg',
23
+ jpg: 'image/jpeg',
24
+ png: 'image/png',
25
+ svg: 'image/svg+xml',
26
+ webp: 'image/webp',
27
+ }
28
+
29
+ /** The MIME type a favicon path declares, read from its extension. Undefined for an unknown extension. */
30
+ export function faviconMimeType(href: string): string | undefined {
31
+ const path = href.split(/[?#]/, 1)[0] ?? ''
32
+ const extension = /\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase()
33
+ return extension === undefined ? undefined : MIME_BY_EXTENSION[extension]
34
+ }
35
+
36
+ function pick(dark: boolean, light: string | undefined, darkPath: string | undefined): string | undefined {
37
+ return dark ? (darkPath ?? light) : (light ?? darkPath)
38
+ }
39
+
40
+ function iconSizes(type: string | undefined): string | undefined {
41
+ if (type === 'image/svg+xml')
42
+ return 'any'
43
+ if (type === 'image/x-icon')
44
+ return undefined
45
+ return '32x32'
46
+ }
47
+
48
+ /**
49
+ * The head links for the app's favicon in one colour mode. The ids let an app
50
+ * replace either link with its own `useHead` entry.
51
+ */
52
+ export function appFaviconLinks(config: AppFaviconConfig | undefined, dark: boolean): AppFaviconLink[] {
53
+ const links: AppFaviconLink[] = []
54
+ const icon = pick(dark, config?.light, config?.dark)
55
+ if (icon !== undefined) {
56
+ const type = faviconMimeType(icon)
57
+ const sizes = iconSizes(type)
58
+ links.push({ id: 'favicon', rel: 'icon', ...(type ? { type } : {}), ...(sizes ? { sizes } : {}), href: icon })
59
+ }
60
+ const touch = pick(dark, config?.appleTouchLight, config?.appleTouchDark)
61
+ if (touch !== undefined)
62
+ links.push({ id: 'apple-touch-icon', rel: 'apple-touch-icon', sizes: '180x180', href: touch })
63
+ return links
64
+ }
@@ -1,8 +1,11 @@
1
1
  import type { ModuleOptions as IconModuleOptions } from '@nuxt/icon'
2
2
  import type { NuxtOptions } from 'nuxt/schema'
3
3
  import type { IconSet } from '../shared/icons'
4
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
4
5
  import { defineNuxtModule } from 'nuxt/kit'
6
+ import { dirname, resolve } from 'pathe'
5
7
  import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
8
+ import { externalLinkMarkerCss } from './external-links'
6
9
 
7
10
  export interface DesignSystemOptions {
8
11
  /**
@@ -10,6 +13,19 @@ export interface DesignSystemOptions {
10
13
  * Install the matching `@iconify-json/<set>` collection in the app.
11
14
  */
12
15
  iconSet?: IconSet
16
+ /**
17
+ * Keep the layer's composables and utils in the app's auto-import scan, and
18
+ * so in `#imports`. Defaults to false: their names, such as a one-argument
19
+ * `formatNumber`, would clash with a site's own helpers. A site imports
20
+ * them by path instead. Nuxt SEO's private `layers/brand` turns it on.
21
+ */
22
+ autoImportUtils?: boolean
23
+ /**
24
+ * Hosts that count as this site. An absolute prose link to one of them gets
25
+ * no external-link arrow. Defaults to none, so every absolute http(s) link
26
+ * is marked. Matches a substring of the href after `//`, so list `www.` too.
27
+ */
28
+ internalLinkHosts?: string[]
13
29
  }
14
30
 
15
31
  /**
@@ -43,5 +59,47 @@ export default defineNuxtModule<DesignSystemOptions>({
43
59
  icon.aliases = { ...iconAliasMap(iconSet), ...icon.aliases }
44
60
  icon.clientBundle ??= {}
45
61
  icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
62
+
63
+ // A plain file, not a Nuxt template. In dev, Nuxt links every
64
+ // `nuxt.options.css` entry the module graph has not loaded, matching by
65
+ // file path. The graph holds a template as a `virtual:nuxt:` id with no
66
+ // such path, so a template stylesheet was linked twice, the second copy
67
+ // after the site's own CSS. The CLI clears the build directory after
68
+ // modules load, so the file is written when the build starts.
69
+ const externalLinks = resolve(nuxt.options.buildDir, 'design-system/external-links.css')
70
+ nuxt.options.css.push(externalLinks)
71
+ nuxt.hook('build:before', () => {
72
+ mkdirSync(dirname(externalLinks), { recursive: true })
73
+ writeFileSync(externalLinks, externalLinkMarkerCss(options.internalLinkHosts ?? []))
74
+ })
75
+
76
+ // The layer's templates pass `aria-*` attributes and a few listeners to
77
+ // components through $attrs, and `app/component-attributes.d.ts` types
78
+ // them. A consumer's tsconfig excludes node_modules, so the declaration
79
+ // never reached its program and a TypeScript 7 native typecheck failed
80
+ // inside the layer's templates. A reference ignores that exclude.
81
+ nuxt.hook('prepare:types', ({ references }) => {
82
+ references.push({ path: resolve(LAYER_ROOTS[0]!, 'app/component-attributes.d.ts') })
83
+ })
84
+
85
+ // Nuxt scans every layer's composables/ and utils/ for auto-imports, and a
86
+ // layer's `imports.scan: false` would merge into the app and stop its scan
87
+ // too. So the layer removes only its own directories here.
88
+ if (!options.autoImportUtils) {
89
+ nuxt.hook('imports:dirs', (dirs) => {
90
+ const kept = dirs.filter(dir => !isLayerPath(dir))
91
+ dirs.splice(0, dirs.length, ...kept)
92
+ })
93
+ }
46
94
  },
47
95
  })
96
+
97
+ const LAYER_ROOTS = [...new Set([resolve(import.meta.dirname, '..'), resolve(realpathSync(resolve(import.meta.dirname, '..')))])]
98
+
99
+ /** Whether a directory sits inside this layer, through a symlink or not. */
100
+ export function isLayerPath(dir: string): boolean {
101
+ const paths = [resolve(dir)]
102
+ if (existsSync(dir))
103
+ paths.push(resolve(realpathSync(dir)))
104
+ return paths.some(path => LAYER_ROOTS.some(root => path === root || path.startsWith(`${root}/`)))
105
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The external-link arrow on outbound prose links. Only an absolute http(s)
3
+ * link that leaves the site gets it; a relative link never matches, and a
4
+ * link around an image is excluded because the arrow would hang off the
5
+ * corner of the figure.
6
+ *
7
+ * Which hosts count as this site is a brand choice, so the rules are built
8
+ * from `designSystem.internalLinkHosts`. A CSS custom property cannot feed an
9
+ * attribute selector, so the hosts have to reach the selector at build time.
10
+ *
11
+ * The marker is a background on a pseudo-element whose only content is a
12
+ * no-break space: the NBSP glues the arrow to the last word, so it can never
13
+ * orphan onto a line of its own, and there is no text for a screen reader to
14
+ * announce. The mask resolves `currentColor` against the image's own initial
15
+ * colour, which is opaque black, so the arrow always paints in the link's
16
+ * colour and the CSS holds no colour of its own.
17
+ */
18
+ export function externalLinkMarkerCss(internalHosts: readonly string[]): string {
19
+ const own = internalHosts
20
+ .map(host => `:not([href*="//${host.replace(/["\\]/g, '\\$&')}"])`)
21
+ .join('')
22
+ const link = `a.prose-link[href^="http"]${own}:not(:has(img))`
23
+ return `${link}::after {
24
+ content: '\\00a0';
25
+ padding-inline-end: 0.72em;
26
+ background-color: currentcolor;
27
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 7h10v10'/%3E%3Cpath d='M7 17 17 7'/%3E%3C/svg%3E");
28
+ mask-repeat: no-repeat;
29
+ mask-position: right center;
30
+ mask-size: 0.72em 0.72em;
31
+ opacity: 0.65;
32
+ vertical-align: baseline;
33
+ }
34
+ ${link}:hover::after {
35
+ opacity: 1;
36
+ }
37
+ `
38
+ }
package/css/global.css CHANGED
@@ -1,9 +1,14 @@
1
1
  @import 'tailwindcss';
2
2
  @import '@nuxt/ui';
3
3
 
4
+ /* Type families. A site sets its own in an unlayered `:root` rule; OG images
5
+ read them from the Tailwind entry instead (README, Brand seams). `--font-title`
6
+ carries page titles (`.text-title`, `font-title`) and defaults to the display
7
+ family. The layer registers no faces; a site loads its own (README). */
4
8
  @theme {
5
9
  --font-sans: 'Hubot Sans', sans-serif;
6
10
  --font-display: 'Hubot Sans', sans-serif;
11
+ --font-title: 'Hubot Sans', sans-serif;
7
12
  --font-mono: 'Fira Code', monospace;
8
13
  }
9
14
 
@@ -271,11 +276,40 @@
271
276
  --color-viz-resource-image: var(--ui-viz-resource-image, var(--_viz-resource-image, var(--color-cyan-500)));
272
277
  --color-viz-resource-font: var(--ui-viz-resource-font, var(--color-pink-500));
273
278
 
274
- --text-color-viz-inp: var(--ui-viz-inp, var(--ui-text-warning));
275
- --text-color-viz-device-tablet: var(--ui-viz-device-tablet, var(--ui-text-warning));
276
- --text-color-viz-rank-7: var(--ui-viz-rank-7, var(--ui-text-warning));
277
- --text-color-viz-current: var(--ui-viz-current, var(--_viz-current-text));
278
- --text-color-viz-comparison: var(--ui-viz-comparison, var(--_viz-comparison-text));
279
+ /* Text side. `--ui-viz-<slot>-text` recolours only the `text-viz-<slot>`
280
+ class, for a mark hue that fails AA as text; unset, text follows the mark. */
281
+ --text-color-viz-clicks: var(--ui-viz-clicks-text, var(--ui-viz-clicks, var(--color-blue-500)));
282
+ --text-color-viz-impressions: var(--ui-viz-impressions-text, var(--ui-viz-impressions, var(--color-purple-500)));
283
+ --text-color-viz-ctr: var(--ui-viz-ctr-text, var(--ui-viz-ctr, var(--_viz-ctr, var(--color-emerald-500))));
284
+ --text-color-viz-position: var(--ui-viz-position-text, var(--ui-viz-position, var(--_viz-position, var(--color-orange-500))));
285
+ --text-color-viz-sessions: var(--ui-viz-sessions-text, var(--ui-viz-sessions, var(--color-blue-500)));
286
+ --text-color-viz-users: var(--ui-viz-users-text, var(--ui-viz-users, var(--_viz-users, var(--color-cyan-500))));
287
+ --text-color-viz-pageviews: var(--ui-viz-pageviews-text, var(--ui-viz-pageviews, var(--color-purple-500)));
288
+ --text-color-viz-lcp: var(--ui-viz-lcp-text, var(--ui-viz-lcp, var(--color-blue-500)));
289
+ --text-color-viz-inp: var(--ui-viz-inp-text, var(--ui-viz-inp, var(--ui-text-warning)));
290
+ --text-color-viz-cls: var(--ui-viz-cls-text, var(--ui-viz-cls, var(--color-purple-500)));
291
+ --text-color-viz-current: var(--ui-viz-current-text, var(--ui-viz-current, var(--_viz-current-text)));
292
+ --text-color-viz-comparison: var(--ui-viz-comparison-text, var(--ui-viz-comparison, var(--_viz-comparison-text)));
293
+ --text-color-viz-device-desktop: var(--ui-viz-device-desktop-text, var(--ui-viz-device-desktop, var(--_viz-device-desktop, var(--color-blue-400))));
294
+ --text-color-viz-device-mobile: var(--ui-viz-device-mobile-text, var(--ui-viz-device-mobile, var(--_viz-device-mobile, var(--color-green-400))));
295
+ --text-color-viz-device-tablet: var(--ui-viz-device-tablet-text, var(--ui-viz-device-tablet, var(--ui-text-warning)));
296
+ --text-color-viz-brand: var(--ui-viz-brand-text, var(--ui-viz-brand, var(--color-violet-500)));
297
+ --text-color-viz-non-brand: var(--ui-viz-non-brand-text, var(--ui-viz-non-brand, var(--_viz-non-brand, var(--color-emerald-500))));
298
+ --text-color-viz-rank-1: var(--ui-viz-rank-1-text, var(--ui-viz-rank-1, var(--color-blue-500)));
299
+ --text-color-viz-rank-2: var(--ui-viz-rank-2-text, var(--ui-viz-rank-2, var(--color-purple-500)));
300
+ --text-color-viz-rank-3: var(--ui-viz-rank-3-text, var(--ui-viz-rank-3, var(--_viz-rank-3, var(--color-emerald-400))));
301
+ --text-color-viz-rank-4: var(--ui-viz-rank-4-text, var(--ui-viz-rank-4, var(--_viz-rank-4, var(--color-orange-500))));
302
+ --text-color-viz-rank-5: var(--ui-viz-rank-5-text, var(--ui-viz-rank-5, var(--_viz-rank-5, var(--color-cyan-500))));
303
+ --text-color-viz-rank-6: var(--ui-viz-rank-6-text, var(--ui-viz-rank-6, var(--color-pink-500)));
304
+ --text-color-viz-rank-7: var(--ui-viz-rank-7-text, var(--ui-viz-rank-7, var(--ui-text-warning)));
305
+ --text-color-viz-rank-8: var(--ui-viz-rank-8-text, var(--ui-viz-rank-8, var(--color-indigo-400)));
306
+ --text-color-viz-rank-9: var(--ui-viz-rank-9-text, var(--ui-viz-rank-9, var(--_viz-rank-9, var(--color-teal-400))));
307
+ --text-color-viz-rank-10: var(--ui-viz-rank-10-text, var(--ui-viz-rank-10, var(--_viz-rank-10, var(--color-rose-400))));
308
+ --text-color-viz-resource-document: var(--ui-viz-resource-document-text, var(--ui-viz-resource-document, var(--color-blue-500)));
309
+ --text-color-viz-resource-script: var(--ui-viz-resource-script-text, var(--ui-viz-resource-script, var(--_viz-resource-script, var(--color-orange-500))));
310
+ --text-color-viz-resource-stylesheet: var(--ui-viz-resource-stylesheet-text, var(--ui-viz-resource-stylesheet, var(--color-purple-500)));
311
+ --text-color-viz-resource-image: var(--ui-viz-resource-image-text, var(--ui-viz-resource-image, var(--_viz-resource-image, var(--color-cyan-500))));
312
+ --text-color-viz-resource-font: var(--ui-viz-resource-font-text, var(--ui-viz-resource-font, var(--color-pink-500)));
279
313
  }
280
314
 
281
315
  :root {
@@ -541,7 +575,13 @@
541
575
  touch-action: manipulation;
542
576
  }
543
577
 
544
- .light {
578
+ /* Surface seam. The mode and dashboard blocks below sit in `:where()`, so
579
+ they carry no specificity: a site's plain `:root` rule sets any surface
580
+ token in both modes, and its `.dark` rule sets dark, whichever stylesheet
581
+ loads last. Nuxt UI's own defaults sit in `@layer theme` and still lose to
582
+ these. Source order decides between these blocks, so the dashboard blocks
583
+ stay after the mode blocks. */
584
+ :where(.light) {
545
585
  color-scheme: light;
546
586
  /* Bind surfaces to `--ui-color-neutral-*` (Nuxt UI's neutral alias → the
547
587
  hue-292 slate override) rather than stock `--color-neutral-*` (cold hue
@@ -552,11 +592,13 @@
552
592
  --ui-bg-elevated: var(--ui-color-neutral-100);
553
593
  --ui-bg-accented: var(--ui-color-neutral-200);
554
594
  --ui-bg-inverted: var(--ui-color-neutral-900);
555
- --ui-text-dimmed: oklch(47.5% 0.052 292);
595
+ /* Text seam. These three sit on Nuxt SEO's violet hue, off the neutral
596
+ ramp; a site re-hues them by setting `--ui-light-text*` (README). */
597
+ --ui-text-dimmed: var(--ui-light-text-dimmed, oklch(47.5% 0.052 292));
556
598
  --ui-text-muted: var(--ui-color-neutral-600);
557
599
  --ui-text-toned: var(--ui-color-neutral-700);
558
- --ui-text: oklch(37% 0.044 285);
559
- --ui-text-highlighted: oklch(21% 0.042 285);
600
+ --ui-text: var(--ui-light-text, oklch(37% 0.044 285));
601
+ --ui-text-highlighted: var(--ui-light-text-highlighted, oklch(21% 0.042 285));
560
602
  --ui-text-inverted: #fff;
561
603
  --ui-border: var(--ui-color-neutral-200);
562
604
  --ui-border-muted: var(--ui-color-neutral-100);
@@ -573,7 +615,7 @@
573
615
  --nuxtseo-brand-tile-ink: oklch(98.4% 0.005 292);
574
616
  }
575
617
 
576
- .dark {
618
+ :where(.dark) {
577
619
  color-scheme: dark;
578
620
  /* Tier ladder in dark: 8.5 → 15 → 21 → 30. The old 7.5 → 13 gap read flat
579
621
  on large surfaces; the new deltas give nav / body / elevated cards a
@@ -1155,29 +1197,30 @@ textarea {
1155
1197
  .section-accent-sky { @apply bg-sky-50 dark:bg-sky-500/5 border-sky-200/50 dark:border-sky-500/10; }
1156
1198
 
1157
1199
  /* Dashboard mauve palette: editorial monochrome tone reserved for /pro/dashboard.
1158
- Marketing/docs pages stay on the warm-slate neutral (hue 285). */
1159
- .dashboard-theme {
1160
- --ui-color-neutral-50: var(--color-mauve-50, oklch(98.5% 0 0));
1161
- --ui-color-neutral-100: var(--color-mauve-100, oklch(96.7% 0.001 286.375));
1162
- --ui-color-neutral-200: var(--color-mauve-200, oklch(92% 0.004 286.32));
1163
- --ui-color-neutral-300: var(--color-mauve-300, oklch(87.1% 0.006 286.286));
1164
- --ui-color-neutral-400: var(--color-mauve-400, oklch(70.5% 0.015 286.067));
1165
- --ui-color-neutral-500: var(--color-mauve-500, oklch(55.2% 0.016 285.938));
1166
- --ui-color-neutral-600: var(--color-mauve-600, oklch(44.2% 0.017 285.786));
1167
- --ui-color-neutral-700: var(--color-mauve-700, oklch(37.4% 0.016 285.822));
1168
- --ui-color-neutral-800: var(--color-mauve-800, oklch(27.4% 0.006 286.033));
1169
- --ui-color-neutral-900: var(--color-mauve-900, oklch(21% 0.006 285.885));
1170
- --ui-color-neutral-950: var(--color-mauve-950, oklch(14.1% 0.005 285.823));
1171
- }
1172
-
1173
- .light.dashboard-theme {
1200
+ Marketing/docs pages stay on the warm-slate neutral (hue 285). A site keeps
1201
+ its own ramp here by setting `--ui-dashboard-neutral-<step>` (README). */
1202
+ :where(.dashboard-theme) {
1203
+ --ui-color-neutral-50: var(--ui-dashboard-neutral-50, var(--color-mauve-50, oklch(98.5% 0 0)));
1204
+ --ui-color-neutral-100: var(--ui-dashboard-neutral-100, var(--color-mauve-100, oklch(96.7% 0.001 286.375)));
1205
+ --ui-color-neutral-200: var(--ui-dashboard-neutral-200, var(--color-mauve-200, oklch(92% 0.004 286.32)));
1206
+ --ui-color-neutral-300: var(--ui-dashboard-neutral-300, var(--color-mauve-300, oklch(87.1% 0.006 286.286)));
1207
+ --ui-color-neutral-400: var(--ui-dashboard-neutral-400, var(--color-mauve-400, oklch(70.5% 0.015 286.067)));
1208
+ --ui-color-neutral-500: var(--ui-dashboard-neutral-500, var(--color-mauve-500, oklch(55.2% 0.016 285.938)));
1209
+ --ui-color-neutral-600: var(--ui-dashboard-neutral-600, var(--color-mauve-600, oklch(44.2% 0.017 285.786)));
1210
+ --ui-color-neutral-700: var(--ui-dashboard-neutral-700, var(--color-mauve-700, oklch(37.4% 0.016 285.822)));
1211
+ --ui-color-neutral-800: var(--ui-dashboard-neutral-800, var(--color-mauve-800, oklch(27.4% 0.006 286.033)));
1212
+ --ui-color-neutral-900: var(--ui-dashboard-neutral-900, var(--color-mauve-900, oklch(21% 0.006 285.885)));
1213
+ --ui-color-neutral-950: var(--ui-dashboard-neutral-950, var(--color-mauve-950, oklch(14.1% 0.005 285.823)));
1214
+ }
1215
+
1216
+ :where(.light.dashboard-theme) {
1174
1217
  background-color: var(--ui-bg);
1175
1218
  --ui-bg: #fff;
1176
1219
  --ui-bg-muted: var(--ui-color-neutral-50);
1177
1220
  --ui-bg-elevated: var(--ui-color-neutral-100);
1178
1221
  --ui-bg-accented: var(--ui-color-neutral-200);
1179
1222
  --ui-bg-inverted: var(--ui-color-neutral-900);
1180
- --ui-text-dimmed: oklch(47.5% 0.018 286);
1223
+ --ui-text-dimmed: var(--ui-dashboard-light-text-dimmed, oklch(47.5% 0.018 286));
1181
1224
  --ui-text-muted: var(--ui-color-neutral-600);
1182
1225
  --ui-text-toned: var(--ui-color-neutral-700);
1183
1226
  --ui-text: var(--ui-color-neutral-800);
@@ -1189,7 +1232,7 @@ textarea {
1189
1232
  --ui-border-inverted: var(--ui-color-neutral-900);
1190
1233
  }
1191
1234
 
1192
- .dark.dashboard-theme {
1235
+ :where(.dark.dashboard-theme) {
1193
1236
  background-color: var(--ui-bg);
1194
1237
  --color-mauve-50: oklch(98.5% 0.002 326);
1195
1238
  --color-mauve-100: oklch(96.4% 0.005 325.6);
@@ -1202,22 +1245,13 @@ textarea {
1202
1245
  --color-mauve-800: oklch(24.4% 0.032 320.12);
1203
1246
  --color-mauve-900: oklch(18.9% 0.026 322.12);
1204
1247
  --color-mauve-950: oklch(14.8% 0.018 326);
1205
- --ui-color-neutral-50: var(--color-mauve-50, oklch(98.5% 0 0));
1206
- --ui-color-neutral-100: var(--color-mauve-100, oklch(96.7% 0.001 286.375));
1207
- --ui-color-neutral-200: var(--color-mauve-200, oklch(92% 0.004 286.32));
1208
- --ui-color-neutral-300: var(--color-mauve-300, oklch(87.1% 0.006 286.286));
1209
- --ui-color-neutral-400: var(--color-mauve-400, oklch(70.5% 0.015 286.067));
1210
- --ui-color-neutral-500: var(--color-mauve-500, oklch(55.2% 0.016 285.938));
1211
- --ui-color-neutral-600: var(--color-mauve-600, oklch(44.2% 0.017 285.786));
1212
- --ui-color-neutral-700: var(--color-mauve-700, oklch(37.4% 0.016 285.822));
1213
- --ui-color-neutral-800: var(--color-mauve-800, oklch(27.4% 0.006 286.033));
1214
- --ui-color-neutral-900: var(--color-mauve-900, oklch(21% 0.006 285.885));
1215
- --ui-color-neutral-950: var(--color-mauve-950, oklch(14.1% 0.005 285.823));
1248
+ /* `.dashboard-theme` binds `--ui-color-neutral-*` to these steps on this same
1249
+ element, so the dark values above flow through without a second binding. */
1216
1250
 
1217
1251
  /* Mauve 400 measures 4.48:1 on mauve 700 (bg-accented), so muted and dimmed
1218
1252
  share one step just above it: 4.6:1 there, 7.7:1 on bg-default. */
1219
- --ui-text-dimmed: oklch(71.5% 0.032 323);
1220
- --ui-text-muted: oklch(71.5% 0.032 323);
1253
+ --ui-text-dimmed: var(--ui-dashboard-dark-text-dimmed, oklch(71.5% 0.032 323));
1254
+ --ui-text-muted: var(--ui-dashboard-dark-text-muted, oklch(71.5% 0.032 323));
1221
1255
  --ui-text-toned: var(--ui-color-neutral-300);
1222
1256
  --ui-text: var(--ui-color-neutral-200);
1223
1257
  --ui-text-highlighted: #fff;
@@ -1328,7 +1362,7 @@ textarea {
1328
1362
  mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
1329
1363
  -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
1330
1364
  }
1331
- .ui-atmosphere[data-geometry="bloom"] {
1365
+ .ui-atmosphere[data-geometry="bloom"][data-bloom-mask] {
1332
1366
  mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
1333
1367
  -webkit-mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
1334
1368
  }
@@ -1338,7 +1372,8 @@ textarea {
1338
1372
  }
1339
1373
  /* veil = full scrim tint over a faded preview; no mask, softer by default */
1340
1374
 
1341
- /* grain — baked in, static (soft-light so it reads as tooth, not dirt) */
1375
+ /* grain — opt in through `grain` or `designSystem.atmosphere.grain`; static,
1376
+ soft-light so it reads as tooth, not dirt */
1342
1377
  .ui-atmosphere[data-grain]::after {
1343
1378
  content: "";
1344
1379
  position: absolute;
@@ -1360,7 +1395,7 @@ textarea {
1360
1395
  /* Page title — the largest in-page heading (page headers, hero metrics). Size is
1361
1396
  a role var so it stays editorial on marketing, compact inside .dashboard-theme. */
1362
1397
  .text-title {
1363
- font-family: var(--font-display);
1398
+ font-family: var(--font-title);
1364
1399
  font-size: var(--size-title);
1365
1400
  font-weight: var(--wght-title);
1366
1401
  font-stretch: var(--wdth-heading);
@@ -1449,10 +1484,40 @@ textarea {
1449
1484
  need primary/success/current keep overriding it (an unlayered rule would beat
1450
1485
  the utility and silently force muted). */
1451
1486
  @layer components {
1487
+ /* Type seam: a site sets `--ui-label-size`, `--ui-label-weight`,
1488
+ `--ui-label-case` and `--ui-label-tracking` at `:root` to retune every
1489
+ label at once (README, Brand seams). Unset, each keeps this spec. */
1452
1490
  .text-label {
1453
- @apply text-[10px] font-semibold uppercase tracking-wider;
1491
+ font-size: var(--ui-label-size, 10px);
1492
+ font-weight: var(--ui-label-weight, 600);
1493
+ text-transform: var(--ui-label-case, uppercase);
1494
+ letter-spacing: var(--ui-label-tracking, 0.05em);
1454
1495
  color: var(--ui-text-muted);
1455
1496
  }
1497
+
1498
+ /* Table type seam. Cells read `--ui-table-cell-size` and
1499
+ `--ui-table-cell-leading`; header labels read `--ui-table-head-size`, then
1500
+ the label size. Declared after `.text-label`, so a header carrying both
1501
+ takes the table size. Unset leading inherits, as the old rules did. */
1502
+ .ui-table-cell {
1503
+ font-size: var(--ui-table-cell-size, var(--text-xs));
1504
+ line-height: var(--ui-table-cell-leading, var(--text-xs--line-height));
1505
+ }
1506
+
1507
+ .ui-table-head {
1508
+ font-size: var(--ui-table-head-size, var(--ui-label-size, 10px));
1509
+ line-height: var(--ui-table-head-leading);
1510
+ }
1511
+
1512
+ /* `UiTrend` sizes `xs`; `2xs` sits below, unlayered like `.text-mini`. */
1513
+ .ui-trend-xs {
1514
+ font-size: var(--ui-trend-xs-size, var(--text-xs));
1515
+ line-height: var(--ui-trend-xs-leading, var(--text-xs--line-height));
1516
+ }
1517
+ }
1518
+
1519
+ .ui-trend-2xs {
1520
+ font-size: var(--ui-trend-2xs-size, 10px);
1456
1521
  }
1457
1522
 
1458
1523
  /* Micro label — the 9px tier below .text-label, for the tiniest tags (table
@@ -1739,32 +1804,8 @@ pre.prose-pre::-webkit-scrollbar-thumb {
1739
1804
  border-radius: 9999px;
1740
1805
  }
1741
1806
 
1742
- /* Outbound prose links carry the same arrow the docs overflow menu already
1743
- uses for an external item, so "this leaves the site" means one thing in both
1744
- places. Only absolute http(s) links that are not ours: every `/docs/*` and
1745
- `/learn-seo/*` link is relative and never matches. A link around an image is
1746
- excluded — the arrow would hang off the corner of the figure.
1747
-
1748
- The marker is a background on a pseudo-element whose only content is a
1749
- no-break space: the NBSP glues the arrow to the last word, so it can never
1750
- orphan onto a line of its own, and there is no text for a screen reader to
1751
- announce. The mask resolves `currentColor` against the image's own initial
1752
- colour, which is opaque black, so the arrow always paints in the link's
1753
- colour and the CSS holds no colour of its own. */
1754
- a.prose-link[href^="http"]:not([href*="//nuxtseo.com"]):not([href*="//www.nuxtseo.com"]):not(:has(img))::after {
1755
- content: '\00a0';
1756
- padding-inline-end: 0.72em;
1757
- background-color: currentcolor;
1758
- mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 7h10v10'/%3E%3Cpath d='M7 17 17 7'/%3E%3C/svg%3E");
1759
- mask-repeat: no-repeat;
1760
- mask-position: right center;
1761
- mask-size: 0.72em 0.72em;
1762
- opacity: 0.65;
1763
- vertical-align: baseline;
1764
- }
1765
- a.prose-link[href^="http"]:not([href*="//nuxtseo.com"]):not([href*="//www.nuxtseo.com"]):not(:has(img)):hover::after {
1766
- opacity: 1;
1767
- }
1807
+ /* The external-link arrow on outbound prose links lives in
1808
+ build/external-links.ts: its own-site hosts are a build option. */
1768
1809
 
1769
1810
  /* Inline code inside a link. `prose.code` is an atomic inline (`inline-block`
1770
1811
  with its own fill), so the link underline stopped at the pill and the pill's
package/nuxt.config.ts CHANGED
@@ -80,33 +80,23 @@ export default defineNuxtConfig({
80
80
  ],
81
81
  },
82
82
 
83
- // Canonical type stack for every surface that extends this layer (root app
84
- // + apps/brand-kit brand-kit). Keep in sync via this single block —
85
- // don't redeclare in root or app configs.
83
+ // The type stack is a brand seam. The layer ships and registers no faces, so
84
+ // a site sets its own through `fonts.families` and the `--font-*` tokens.
86
85
  fonts: {
87
- // Both families are global. CSS variable discovery cannot add another
88
- // face, so avoid parsing every component style during each Vite build.
86
+ // CSS variable discovery cannot add another face, so avoid parsing every
87
+ // component style during each Vite build.
89
88
  processCSSVariables: false,
90
- families: [
91
- // Keep build inputs in this layer. Provider downloads make every app
92
- // build depend on Google Fonts availability after compilation finishes.
93
- // Hubot Sans keeps variable weight and width axes for the design tokens.
94
- {
95
- name: 'Hubot Sans',
96
- src: '/fonts/hubot-sans-var-latin.woff2',
97
- weight: '200 900',
98
- stretch: '75% 125%',
99
- style: 'normal',
100
- global: true,
101
- },
102
- {
103
- name: 'Fira Code',
104
- src: '/fonts/fira-code-var-latin.woff2',
105
- weight: '400 500',
106
- style: 'normal',
107
- global: true,
108
- },
109
- ],
89
+ },
90
+
91
+ vite: {
92
+ optimizeDeps: {
93
+ // The layer's tables run @tanstack/vue-table 9, and @nuxt/ui's UTable
94
+ // runs 8. Vite pre-bundles one copy per package name, so whichever it
95
+ // meets first serves both, and a layer table fails with "does not
96
+ // provide an export named 'useTable'". Unbundled, each importer resolves
97
+ // its own copy.
98
+ exclude: ['@tanstack/vue-table', '@tanstack/table-core'],
99
+ },
110
100
  },
111
101
 
112
102
  icon: {
@@ -125,7 +115,10 @@ export default defineNuxtConfig({
125
115
  experimental: { componentDetection: true },
126
116
  content: true,
127
117
  theme: {
128
- colors: ['primary', 'secondary', 'tertiary', 'info', 'success', 'warning', 'error', 'pro'],
118
+ // Each colour adds its variants to every Nuxt UI component theme in the
119
+ // CSS every page inlines. No app.config maps a `tertiary` colour and no
120
+ // component asks for one, so it is not listed.
121
+ colors: ['primary', 'secondary', 'info', 'success', 'warning', 'error', 'pro'],
129
122
  },
130
123
  },
131
124
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@nuxtseo/design-system",
3
3
  "type": "module",
4
- "version": "0.1.0",
5
- "description": "Nuxt SEO design system layer: UI primitives, tokens, fonts, motion, and chart helpers. Extend it from a Nuxt app.",
4
+ "version": "0.1.2",
5
+ "description": "Nuxt SEO design system layer: UI primitives, tokens, motion, and chart helpers. Extend it from a Nuxt app.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com",
8
8
  "repository": {
@@ -26,7 +26,10 @@
26
26
  "./composables/*": "./app/composables/*.ts",
27
27
  "./package.json": "./package.json",
28
28
  "./server/*": "./server/*.ts",
29
- "./shared/*": "./shared/*.ts"
29
+ "./shared/contracts": "./shared/contracts/index.ts",
30
+ "./shared/icons": "./shared/icons/index.ts",
31
+ "./shared/*": "./shared/*.ts",
32
+ "./types": "./types/index.ts"
30
33
  },
31
34
  "main": "./nuxt.config.ts",
32
35
  "files": [
@@ -34,18 +37,23 @@
34
37
  "build",
35
38
  "css",
36
39
  "nuxt.config.ts",
37
- "public",
38
40
  "server",
39
41
  "shared",
40
42
  "types"
41
43
  ],
42
44
  "peerDependencies": {
45
+ "@nuxt/scripts": "^1.3.10 || ^2.0.0-beta.14",
46
+ "@nuxt/ui": "^4.11.1",
47
+ "@vueuse/core": "^14.0.0 || ^15.0.0",
48
+ "motion-v": "^2.4.2",
43
49
  "nuxt": "^4.6.0",
50
+ "tailwindcss": "^4.3.0",
44
51
  "typescript": ">=5.8.0 <7",
45
- "vue": "^3.5.0"
52
+ "vue": "^3.5.0",
53
+ "zod": "^4.0.0"
46
54
  },
47
55
  "dependencies": {
48
- "@comark/nuxt": "0.7.0",
56
+ "@comark/nuxt": "^0.7.0",
49
57
  "@iconify-json/hugeicons": "^1.2.34",
50
58
  "@iconify-json/logos": "^1.2.14",
51
59
  "@iconify-json/lucide": "^1.2.130",
@@ -55,27 +63,27 @@
55
63
  "@nuxt/fonts": "^1.0.1",
56
64
  "@nuxt/icon": "^2.5.1",
57
65
  "@nuxt/image": "^2.1.0",
58
- "@nuxt/scripts": "2.0.0-beta.14",
59
- "@nuxt/ui": "^4.11.1",
60
66
  "@tanstack/table-core": "^9.2.4",
61
67
  "@tanstack/vue-table": "^9.2.4",
62
68
  "@unovis/ts": "^1.6.7",
63
69
  "@unovis/vue": "^1.6.7",
64
- "@vueuse/core": "^14.4.0",
65
- "motion-v": "2.4.2",
66
70
  "nuxtseo-shared": "^6.0.2",
67
71
  "pathe": "^2.0.3",
68
72
  "rangi": "^2.2.0",
69
- "reka-ui": "^2.10.4",
70
- "tailwindcss": "^4.3.3",
71
- "zod": "^4.6.7"
73
+ "reka-ui": "^2.10.4"
72
74
  },
73
75
  "devDependencies": {
76
+ "@nuxt/scripts": "^2.0.0-beta.14",
77
+ "@nuxt/ui": "^4.11.1",
78
+ "@vueuse/core": "^14.4.0",
74
79
  "ai": "^7.0.122",
80
+ "motion-v": "2.4.2",
75
81
  "nuxt": "4.6.1",
76
82
  "sharp": "^0.35.4",
77
83
  "site-typecheck": "0.0.0",
78
- "vue": "^3.5.43"
84
+ "tailwindcss": "^4.3.3",
85
+ "vue": "^3.5.43",
86
+ "zod": "^4.6.7"
79
87
  },
80
88
  "publishConfig": {
81
89
  "access": "public"
Binary file
Binary file
Binary file
Binary file
Binary file