@nuxtseo/design-system 0.1.0 → 0.1.1

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.
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,20 @@ 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+, `motion-v` 2.4+, `tailwindcss` 4.3+, and `zod` 4. The Vue SFC compiler resolves the layer's prop types through the TypeScript JS API, and TypeScript 7 has none.
43
+ - The layer shares your copy of each peer, so the app runs one Nuxt UI, one Tailwind, one motion-v context, and one zod. pnpm and npm install a missing peer for you. You need no `overrides` for the layer.
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.
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
+ - The layer ships no favicon and loads no fonts. Set both through the brand seams below.
45
49
 
46
50
  ### Overrides
47
51
 
48
52
  | What | How |
49
53
  | --- | --- |
50
54
  | 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-*`. |
55
+ | 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
56
  | One instance | Props and slots on the component. |
53
57
  | Icon set, chart colours, icon colour roles | The brand seams below. |
54
58
 
@@ -77,12 +81,86 @@ export default defineNuxtConfig({
77
81
  })
78
82
  ```
79
83
 
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.
84
+ **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'`.
85
+
86
+ ```ts
87
+ // app.config.ts
88
+ export default {
89
+ favicon: {
90
+ light: '/icon-light.svg',
91
+ dark: '/icon-dark.svg',
92
+ appleTouchLight: '/touch-icon.png',
93
+ appleTouchDark: '/touch-icon-dark.png',
94
+ },
95
+ }
96
+ ```
97
+
98
+ **Type.** Set `--font-sans`, `--font-display`, `--font-title`, and `--font-mono` in your stylesheet, and register the faces with `fonts.families`. `--font-title` carries page titles (`.text-title` and the `font-title` utility). Its default is the display family. The layer self-hosts Nuxt SEO's Hubot Sans and Fira Code but registers neither by default, so no page preloads them. To use them, opt in:
99
+
100
+ ```ts
101
+ // nuxt.config.ts
102
+ export default defineNuxtConfig({
103
+ extends: ['@nuxtseo/design-system'],
104
+ designSystem: { fonts: true },
105
+ })
106
+ ```
107
+
108
+ **Own-site links.** An absolute HTTP or HTTPS prose link gets an external-link arrow. List the hosts that count as your site, and links to them get none. Unset, the layer lists no host.
109
+
110
+ ```ts
111
+ // nuxt.config.ts
112
+ export default defineNuxtConfig({
113
+ designSystem: { internalLinkHosts: ['example.com', 'www.example.com'] },
114
+ })
115
+ ```
116
+
117
+ **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.
118
+
119
+ ```css
120
+ /* app/assets/css/og-theme.css */
121
+ @import '@nuxtseo/design-system/css/global.css';
122
+
123
+ @theme {
124
+ --font-sans: 'DM Sans', system-ui, sans-serif;
125
+ --font-display: 'Poppins', system-ui, sans-serif;
126
+ --font-title: 'Poppins', system-ui, sans-serif;
127
+ }
128
+ ```
129
+
130
+ ```ts
131
+ // nuxt.config.ts
132
+ export default defineNuxtConfig({
133
+ alias: { '#tailwindcss': fileURLToPath(new URL('./app/assets/css/og-theme.css', import.meta.url)) },
134
+ })
135
+ ```
136
+
137
+ **Text and dashboard neutrals.** Three light mode text tokens sit on Nuxt SEO's violet hue, off the neutral ramp. The dashboard (`.dashboard-theme`) swaps the neutral ramp to mauve. Set these properties to re-hue them; never override the selectors. Unset, each keeps Nuxt SEO's value.
138
+
139
+ | Property | Sets |
140
+ | --- | --- |
141
+ | `--ui-light-text`, `--ui-light-text-dimmed`, `--ui-light-text-highlighted` | `--ui-text`, `--ui-text-dimmed`, `--ui-text-highlighted` in light mode |
142
+ | `--ui-dashboard-neutral-50` to `--ui-dashboard-neutral-950` | The dashboard's neutral ramp, both modes |
143
+ | `--ui-dashboard-light-text-dimmed` | Dashboard `--ui-text-dimmed`, light mode |
144
+ | `--ui-dashboard-dark-text-dimmed`, `--ui-dashboard-dark-text-muted` | Dashboard `--ui-text-dimmed` and `--ui-text-muted`, dark mode |
145
+
146
+ ```css
147
+ :root {
148
+ --ui-light-text: var(--color-olive-800);
149
+ --ui-dashboard-neutral-50: var(--color-olive-50);
150
+ /* and each step to 950 */
151
+ }
152
+ ```
153
+
154
+ 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.
155
+
156
+ **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
157
 
82
158
  ```css
83
159
  :root {
84
160
  --ui-viz-clicks: var(--ui-primary);
85
161
  --ui-viz-neutral: #94a3b8;
162
+ --ui-viz-comparison: #a3a292;
163
+ --ui-viz-comparison-text: #67713a;
86
164
  }
87
165
  ```
88
166
 
@@ -92,11 +170,13 @@ A palette `hex` field is a CSS colour, such as `var(--ui-viz-clicks, #3b82f6)`.
92
170
 
93
171
  ```ts
94
172
  // app.config.ts
95
- export default defineAppConfig({
173
+ export default {
96
174
  designSystem: {
97
175
  iconColors: { lime: { bg: 'bg-lime-500/10', text: 'text-lime-500' } },
98
176
  },
99
- })
177
+ }
100
178
  ```
101
179
 
180
+ **Onboarding copy.** `UiOnboardingAnswers` takes an `intents` prop for the goal list and a `copy` prop for its strings: `roleQuestion`, `siteCountQuestion`, `seoExperienceQuestion`, `intentsQuestion`, `intentsHint`, `otherLabel`, and `otherPlaceholder`. Pass any of them. Unset keys keep the strings in `ONBOARDING_ANSWERS_COPY`. Onboarding Answers store the option ids, so the ids never change.
181
+
102
182
  **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.
@@ -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' }"
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
- import type { OnboardingAnswersDraft } from './onboarding-answers'
2
+ import type { OnboardingAnswersCopy, OnboardingAnswersDraft } from './onboarding-answers'
3
3
  import type { OnboardingIntent } from './onboarding-intents'
4
- import { useId } from 'vue'
4
+ import { computed, useId } from 'vue'
5
5
  import { UiIcon, UTextarea } from '#components'
6
6
  import {
7
+ ONBOARDING_ANSWERS_COPY,
7
8
  ONBOARDING_OTHER_MAX_LENGTH,
8
- ONBOARDING_QUESTIONS,
9
9
  ONBOARDING_ROLES,
10
10
  ONBOARDING_SEO_EXPERIENCE,
11
11
  ONBOARDING_SITE_COUNTS,
@@ -18,27 +18,29 @@ import { ONBOARDING_INTENTS } from './onboarding-intents'
18
18
  // the live teams/create wizard and the brand-kit showcase both v-model the
19
19
  // draft and decide when to save it.
20
20
  //
21
- // The goals list and its question name the product, so a site that extends this
22
- // layer passes its own. The other questions and their ids are shared.
21
+ // The goals list and the copy name the product, so a site that extends this
22
+ // layer passes its own `intents` and any `copy` keys it rewords. The option
23
+ // ids are shared and stored, so they never change.
23
24
  const {
24
25
  intents = ONBOARDING_INTENTS,
25
- intentsQuestion = ONBOARDING_QUESTIONS.intents,
26
+ copy: copyOverrides = {},
26
27
  } = defineProps<{
27
28
  intents?: readonly OnboardingIntent[]
28
- intentsQuestion?: string
29
+ copy?: Partial<OnboardingAnswersCopy>
29
30
  }>()
31
+ const copy = computed<OnboardingAnswersCopy>(() => ({ ...ONBOARDING_ANSWERS_COPY, ...copyOverrides }))
30
32
  const answers = defineModel<OnboardingAnswersDraft>({ required: true })
31
33
 
32
34
  const id = useId()
33
35
  const otherId = `${id}-other`
34
36
 
35
- const singleQuestions = [
36
- { key: 'role', question: ONBOARDING_QUESTIONS.role, options: ONBOARDING_ROLES },
37
- { key: 'siteCount', question: ONBOARDING_QUESTIONS.siteCount, options: ONBOARDING_SITE_COUNTS },
38
- { key: 'seoExperience', question: ONBOARDING_QUESTIONS.seoExperience, options: ONBOARDING_SEO_EXPERIENCE },
39
- ] as const
37
+ const singleQuestions = computed(() => [
38
+ { key: 'role', question: copy.value.roleQuestion, options: ONBOARDING_ROLES },
39
+ { key: 'siteCount', question: copy.value.siteCountQuestion, options: ONBOARDING_SITE_COUNTS },
40
+ { key: 'seoExperience', question: copy.value.seoExperienceQuestion, options: ONBOARDING_SEO_EXPERIENCE },
41
+ ] as const)
40
42
 
41
- type SingleKey = (typeof singleQuestions)[number]['key']
43
+ type SingleKey = 'role' | 'siteCount' | 'seoExperience'
42
44
 
43
45
  function pick(key: SingleKey, value: string) {
44
46
  answers.value = { ...answers.value, [key]: answers.value[key] === value ? undefined : value }
@@ -82,10 +84,10 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
82
84
 
83
85
  <fieldset class="space-y-2.5">
84
86
  <legend class="text-sm font-medium text-highlighted">
85
- {{ intentsQuestion }}
87
+ {{ copy.intentsQuestion }}
86
88
  </legend>
87
89
  <p class="text-xs text-muted">
88
- Pick any that fit.
90
+ {{ copy.intentsHint }}
89
91
  </p>
90
92
  <div class="flex flex-wrap gap-2">
91
93
  <button
@@ -107,7 +109,7 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
107
109
  </div>
108
110
  <div class="space-y-1.5 pt-1">
109
111
  <label :for="otherId" class="text-xs text-muted">
110
- Anything else? Optional.
112
+ {{ copy.otherLabel }}
111
113
  </label>
112
114
  <UTextarea
113
115
  :id="otherId"
@@ -115,7 +117,7 @@ const restClass = 'border-accented bg-default/80 text-default hover:border-prima
115
117
  :maxlength="ONBOARDING_OTHER_MAX_LENGTH"
116
118
  :rows="2"
117
119
  autoresize
118
- placeholder="Tell us what you're after"
120
+ :placeholder="copy.otherPlaceholder"
119
121
  class="w-full"
120
122
  @update:model-value="setOther(String($event ?? ''))"
121
123
  />
@@ -18,7 +18,7 @@ import { USelect } from '#components'
18
18
  defineOptions({ inheritAttrs: false })
19
19
 
20
20
  const props = defineProps<Props>()
21
- const emit = defineEmits<SelectEmits<T, VK, M>>()
21
+ const emit = defineEmits<Omit<SelectEmits<T, VK, M>, 'update:modelValue'> & { 'update:modelValue': [value: ModelValue] }>()
22
22
  /**
23
23
  * `open`, `defaultOpen` and `portal` are omitted deliberately, and dropping any
24
24
  * of them from this list breaks every select in the app.
@@ -37,13 +37,26 @@ const emit = defineEmits<SelectEmits<T, VK, M>>()
37
37
  * No caller passes any of the three, so omitting them costs nothing.
38
38
  */
39
39
  type Props = Omit<SelectProps<T, VK, M>, 'defaultValue' | 'modelModifiers' | 'open' | 'defaultOpen' | 'portal'>
40
+ /**
41
+ * The value USelect emits is always a picked item, so the model never emits
42
+ * `undefined` and a caller may v-model a ref that never holds it. The type is
43
+ * read from SelectProps, not rebuilt from Nuxt UI's private ExcludeItem
44
+ * generic. Inside this generic component TypeScript 7.1 cannot prove that
45
+ * USelect's own emitted type matches it, so `updateModel` takes the value
46
+ * untyped and the caller-facing emit carries the type.
47
+ */
48
+ type ModelValue = NonNullable<SelectProps<T, VK, M>['modelValue']>
49
+
50
+ function updateModel(value: unknown) {
51
+ emit('update:modelValue', value as ModelValue)
52
+ }
40
53
  </script>
41
54
 
42
55
  <template>
43
56
  <!-- @vue-generic {T, VK, M} -->
44
57
  <USelect
45
58
  v-bind="{ ...props, ...$attrs }"
46
- @update:model-value="emit('update:modelValue', $event)"
59
+ @update:model-value="updateModel"
47
60
  @blur="emit('blur', $event)"
48
61
  @change="emit('change', $event)"
49
62
  @focus="emit('focus', $event)"
@@ -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
@@ -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
  })
@@ -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,9 @@
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 { defineNuxtModule } from 'nuxt/kit'
4
+ import { addTemplate, defineNuxtModule } from 'nuxt/kit'
5
5
  import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
6
+ import { externalLinkMarkerCss } from './external-links'
6
7
 
7
8
  export interface DesignSystemOptions {
8
9
  /**
@@ -10,6 +11,20 @@ export interface DesignSystemOptions {
10
11
  * Install the matching `@iconify-json/<set>` collection in the app.
11
12
  */
12
13
  iconSet?: IconSet
14
+ /**
15
+ * Load Nuxt SEO's Hubot Sans and Fira Code faces as global families, so
16
+ * every page preloads them. Defaults to false: a site sets its own families
17
+ * through `fonts.families` and the `--font-*` tokens. The layer's
18
+ * `modules:before` hook reads it, because @nuxt/fonts reads its families
19
+ * when it installs, which can come before this module's setup.
20
+ */
21
+ fonts?: boolean
22
+ /**
23
+ * Hosts that count as this site. An absolute prose link to one of them gets
24
+ * no external-link arrow. Defaults to none, so every absolute http(s) link
25
+ * is marked. Matches a substring of the href after `//`, so list `www.` too.
26
+ */
27
+ internalLinkHosts?: string[]
13
28
  }
14
29
 
15
30
  /**
@@ -43,5 +58,12 @@ export default defineNuxtModule<DesignSystemOptions>({
43
58
  icon.aliases = { ...iconAliasMap(iconSet), ...icon.aliases }
44
59
  icon.clientBundle ??= {}
45
60
  icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
61
+
62
+ const externalLinks = addTemplate({
63
+ filename: 'design-system/external-links.css',
64
+ getContents: () => externalLinkMarkerCss(options.internalLinkHosts ?? []),
65
+ write: true,
66
+ })
67
+ nuxt.options.css.push(externalLinks.dst)
46
68
  },
47
69
  })
@@ -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/build/fonts.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { FontFamilyManualOverride } from '@nuxt/fonts'
2
+
3
+ /**
4
+ * Nuxt SEO's own faces, self-hosted from this layer's `public/fonts/`. Builds
5
+ * never download them from a provider, so an app build does not depend on
6
+ * Google Fonts availability. Hubot Sans keeps its variable weight and width
7
+ * axes for the design tokens.
8
+ *
9
+ * A site loads them only when it sets `designSystem: { fonts: true }`. A
10
+ * global family is preloaded on every page, so a site with its own type stack
11
+ * would otherwise pay for two faces it never renders.
12
+ */
13
+ export const NUXT_SEO_FONT_FAMILIES: readonly FontFamilyManualOverride[] = [
14
+ {
15
+ name: 'Hubot Sans',
16
+ src: '/fonts/hubot-sans-var-latin.woff2',
17
+ weight: '200 900',
18
+ stretch: '75% 125%',
19
+ style: 'normal',
20
+ global: true,
21
+ },
22
+ {
23
+ name: 'Fira Code',
24
+ src: '/fonts/fira-code-var-latin.woff2',
25
+ weight: '400 500',
26
+ style: 'normal',
27
+ global: true,
28
+ },
29
+ ]
30
+
31
+ /**
32
+ * The font families @nuxt/fonts should register. With `enabled`, Nuxt SEO's
33
+ * faces come first and the app's own families follow; without it, the app's
34
+ * families pass through unchanged.
35
+ */
36
+ export function withNuxtSeoFonts<T>(families: readonly T[] | undefined, enabled: boolean | undefined): Array<T | FontFamilyManualOverride> {
37
+ const own = families ?? []
38
+ return enabled === true ? [...NUXT_SEO_FONT_FAMILIES, ...own] : [...own]
39
+ }
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 faces load only with `designSystem: { fonts: true }`. */
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 {
@@ -552,11 +586,13 @@
552
586
  --ui-bg-elevated: var(--ui-color-neutral-100);
553
587
  --ui-bg-accented: var(--ui-color-neutral-200);
554
588
  --ui-bg-inverted: var(--ui-color-neutral-900);
555
- --ui-text-dimmed: oklch(47.5% 0.052 292);
589
+ /* Text seam. These three sit on Nuxt SEO's violet hue, off the neutral
590
+ ramp; a site re-hues them by setting `--ui-light-text*` (README). */
591
+ --ui-text-dimmed: var(--ui-light-text-dimmed, oklch(47.5% 0.052 292));
556
592
  --ui-text-muted: var(--ui-color-neutral-600);
557
593
  --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);
594
+ --ui-text: var(--ui-light-text, oklch(37% 0.044 285));
595
+ --ui-text-highlighted: var(--ui-light-text-highlighted, oklch(21% 0.042 285));
560
596
  --ui-text-inverted: #fff;
561
597
  --ui-border: var(--ui-color-neutral-200);
562
598
  --ui-border-muted: var(--ui-color-neutral-100);
@@ -1155,19 +1191,20 @@ textarea {
1155
1191
  .section-accent-sky { @apply bg-sky-50 dark:bg-sky-500/5 border-sky-200/50 dark:border-sky-500/10; }
1156
1192
 
1157
1193
  /* Dashboard mauve palette: editorial monochrome tone reserved for /pro/dashboard.
1158
- Marketing/docs pages stay on the warm-slate neutral (hue 285). */
1194
+ Marketing/docs pages stay on the warm-slate neutral (hue 285). A site keeps
1195
+ its own ramp here by setting `--ui-dashboard-neutral-<step>` (README). */
1159
1196
  .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));
1197
+ --ui-color-neutral-50: var(--ui-dashboard-neutral-50, var(--color-mauve-50, oklch(98.5% 0 0)));
1198
+ --ui-color-neutral-100: var(--ui-dashboard-neutral-100, var(--color-mauve-100, oklch(96.7% 0.001 286.375)));
1199
+ --ui-color-neutral-200: var(--ui-dashboard-neutral-200, var(--color-mauve-200, oklch(92% 0.004 286.32)));
1200
+ --ui-color-neutral-300: var(--ui-dashboard-neutral-300, var(--color-mauve-300, oklch(87.1% 0.006 286.286)));
1201
+ --ui-color-neutral-400: var(--ui-dashboard-neutral-400, var(--color-mauve-400, oklch(70.5% 0.015 286.067)));
1202
+ --ui-color-neutral-500: var(--ui-dashboard-neutral-500, var(--color-mauve-500, oklch(55.2% 0.016 285.938)));
1203
+ --ui-color-neutral-600: var(--ui-dashboard-neutral-600, var(--color-mauve-600, oklch(44.2% 0.017 285.786)));
1204
+ --ui-color-neutral-700: var(--ui-dashboard-neutral-700, var(--color-mauve-700, oklch(37.4% 0.016 285.822)));
1205
+ --ui-color-neutral-800: var(--ui-dashboard-neutral-800, var(--color-mauve-800, oklch(27.4% 0.006 286.033)));
1206
+ --ui-color-neutral-900: var(--ui-dashboard-neutral-900, var(--color-mauve-900, oklch(21% 0.006 285.885)));
1207
+ --ui-color-neutral-950: var(--ui-dashboard-neutral-950, var(--color-mauve-950, oklch(14.1% 0.005 285.823)));
1171
1208
  }
1172
1209
 
1173
1210
  .light.dashboard-theme {
@@ -1177,7 +1214,7 @@ textarea {
1177
1214
  --ui-bg-elevated: var(--ui-color-neutral-100);
1178
1215
  --ui-bg-accented: var(--ui-color-neutral-200);
1179
1216
  --ui-bg-inverted: var(--ui-color-neutral-900);
1180
- --ui-text-dimmed: oklch(47.5% 0.018 286);
1217
+ --ui-text-dimmed: var(--ui-dashboard-light-text-dimmed, oklch(47.5% 0.018 286));
1181
1218
  --ui-text-muted: var(--ui-color-neutral-600);
1182
1219
  --ui-text-toned: var(--ui-color-neutral-700);
1183
1220
  --ui-text: var(--ui-color-neutral-800);
@@ -1202,22 +1239,13 @@ textarea {
1202
1239
  --color-mauve-800: oklch(24.4% 0.032 320.12);
1203
1240
  --color-mauve-900: oklch(18.9% 0.026 322.12);
1204
1241
  --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));
1242
+ /* `.dashboard-theme` binds `--ui-color-neutral-*` to these steps on this same
1243
+ element, so the dark values above flow through without a second binding. */
1216
1244
 
1217
1245
  /* Mauve 400 measures 4.48:1 on mauve 700 (bg-accented), so muted and dimmed
1218
1246
  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);
1247
+ --ui-text-dimmed: var(--ui-dashboard-dark-text-dimmed, oklch(71.5% 0.032 323));
1248
+ --ui-text-muted: var(--ui-dashboard-dark-text-muted, oklch(71.5% 0.032 323));
1221
1249
  --ui-text-toned: var(--ui-color-neutral-300);
1222
1250
  --ui-text: var(--ui-color-neutral-200);
1223
1251
  --ui-text-highlighted: #fff;
@@ -1360,7 +1388,7 @@ textarea {
1360
1388
  /* Page title — the largest in-page heading (page headers, hero metrics). Size is
1361
1389
  a role var so it stays editorial on marketing, compact inside .dashboard-theme. */
1362
1390
  .text-title {
1363
- font-family: var(--font-display);
1391
+ font-family: var(--font-title);
1364
1392
  font-size: var(--size-title);
1365
1393
  font-weight: var(--wght-title);
1366
1394
  font-stretch: var(--wdth-heading);
@@ -1739,32 +1767,8 @@ pre.prose-pre::-webkit-scrollbar-thumb {
1739
1767
  border-radius: 9999px;
1740
1768
  }
1741
1769
 
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
- }
1770
+ /* The external-link arrow on outbound prose links lives in
1771
+ build/external-links.ts: its own-site hosts are a build option. */
1768
1772
 
1769
1773
  /* Inline code inside a link. `prose.code` is an atomic inline (`inline-block`
1770
1774
  with its own fill), so the link underline stopped at the pill and the pill's
package/nuxt.config.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { NuxtOptions } from 'nuxt/schema'
2
+ import type { DesignSystemOptions } from './build/design-system'
1
3
  import { defineNuxtConfig } from 'nuxt/config'
4
+ import { useNuxt } from 'nuxt/kit'
2
5
  import { join } from 'pathe'
6
+ import { withNuxtSeoFonts } from './build/fonts'
3
7
  import { UI_ICON_SLOTS } from './shared/icons/ui-slots'
4
8
 
5
9
  const currentDir = import.meta.dirname
@@ -80,33 +84,37 @@ export default defineNuxtConfig({
80
84
  ],
81
85
  },
82
86
 
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.
87
+ // The type stack is a brand seam. The layer self-hosts Nuxt SEO's Hubot Sans
88
+ // and Fira Code in public/fonts/, and the `modules:before` hook below
89
+ // registers them only for a site that sets `designSystem: { fonts: true }`.
86
90
  fonts: {
87
- // Both families are global. CSS variable discovery cannot add another
88
- // face, so avoid parsing every component style during each Vite build.
91
+ // CSS variable discovery cannot add another face, so avoid parsing every
92
+ // component style during each Vite build.
89
93
  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
- ],
94
+ },
95
+
96
+ hooks: {
97
+ // @nuxt/fonts reads its families when it installs, and an app that lists
98
+ // @nuxt/fonts itself can install it before this layer's own module. This
99
+ // hook runs before any module installs.
100
+ 'modules:before': function () {
101
+ const options = useNuxt().options as NuxtOptions & { designSystem?: DesignSystemOptions }
102
+ // `false` turns @nuxt/fonts off, so there is nothing to register.
103
+ if (options.fonts === false)
104
+ return
105
+ options.fonts.families = withNuxtSeoFonts(options.fonts.families, options.designSystem?.fonts)
106
+ },
107
+ },
108
+
109
+ vite: {
110
+ optimizeDeps: {
111
+ // The layer's tables run @tanstack/vue-table 9, and @nuxt/ui's UTable
112
+ // runs 8. Vite pre-bundles one copy per package name, so whichever it
113
+ // meets first serves both, and a layer table fails with "does not
114
+ // provide an export named 'useTable'". Unbundled, each importer resolves
115
+ // its own copy.
116
+ exclude: ['@tanstack/vue-table', '@tanstack/table-core'],
117
+ },
110
118
  },
111
119
 
112
120
  icon: {
@@ -125,7 +133,10 @@ export default defineNuxtConfig({
125
133
  experimental: { componentDetection: true },
126
134
  content: true,
127
135
  theme: {
128
- colors: ['primary', 'secondary', 'tertiary', 'info', 'success', 'warning', 'error', 'pro'],
136
+ // Each colour adds its variants to every Nuxt UI component theme in the
137
+ // CSS every page inlines. No app.config maps a `tertiary` colour and no
138
+ // component asks for one, so it is not listed.
139
+ colors: ['primary', 'secondary', 'info', 'success', 'warning', 'error', 'pro'],
129
140
  },
130
141
  },
131
142
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtseo/design-system",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Nuxt SEO design system layer: UI primitives, tokens, fonts, motion, and chart helpers. Extend it from a Nuxt app.",
6
6
  "license": "MIT",
7
7
  "homepage": "https://nuxtseo.com",
@@ -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": [
@@ -40,12 +43,16 @@
40
43
  "types"
41
44
  ],
42
45
  "peerDependencies": {
46
+ "@nuxt/ui": "^4.11.1",
47
+ "motion-v": "^2.4.2",
43
48
  "nuxt": "^4.6.0",
49
+ "tailwindcss": "^4.3.0",
44
50
  "typescript": ">=5.8.0 <7",
45
- "vue": "^3.5.0"
51
+ "vue": "^3.5.0",
52
+ "zod": "^4.0.0"
46
53
  },
47
54
  "dependencies": {
48
- "@comark/nuxt": "0.7.0",
55
+ "@comark/nuxt": "^0.7.0",
49
56
  "@iconify-json/hugeicons": "^1.2.34",
50
57
  "@iconify-json/logos": "^1.2.14",
51
58
  "@iconify-json/lucide": "^1.2.130",
@@ -55,27 +62,27 @@
55
62
  "@nuxt/fonts": "^1.0.1",
56
63
  "@nuxt/icon": "^2.5.1",
57
64
  "@nuxt/image": "^2.1.0",
58
- "@nuxt/scripts": "2.0.0-beta.14",
59
- "@nuxt/ui": "^4.11.1",
65
+ "@nuxt/scripts": "^2.0.0-beta.14",
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",
70
+ "@vueuse/core": "^14.0.0",
66
71
  "nuxtseo-shared": "^6.0.2",
67
72
  "pathe": "^2.0.3",
68
73
  "rangi": "^2.2.0",
69
- "reka-ui": "^2.10.4",
70
- "tailwindcss": "^4.3.3",
71
- "zod": "^4.6.7"
74
+ "reka-ui": "^2.10.4"
72
75
  },
73
76
  "devDependencies": {
77
+ "@nuxt/ui": "^4.11.1",
74
78
  "ai": "^7.0.122",
79
+ "motion-v": "2.4.2",
75
80
  "nuxt": "4.6.1",
76
81
  "sharp": "^0.35.4",
77
82
  "site-typecheck": "0.0.0",
78
- "vue": "^3.5.43"
83
+ "tailwindcss": "^4.3.3",
84
+ "vue": "^3.5.43",
85
+ "zod": "^4.6.7"
79
86
  },
80
87
  "publishConfig": {
81
88
  "access": "public"
@@ -2,6 +2,8 @@
2
2
 
3
3
  The design system owns these font files. Nuxt copies them into every app that
4
4
  extends this layer. Builds never download fonts from an external provider.
5
+ `build/fonts.ts` registers them only for an app that sets
6
+ `designSystem: { fonts: true }`, as `layers/brand` does for Nuxt SEO.
5
7
 
6
8
  The Hubot Sans file is a latin/latin-ext subset of the official v1.0.1 release
7
9
  face (`HubotSans[slnt,wdth,wght].woff2`). It keeps the weight axis (200 to 900)
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/public/icon.png DELETED
Binary file
Binary file
Binary file