@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 +87 -7
- package/app/app.config.ts +6 -10
- package/app/components/data/UiDataTableSection.vue +1 -0
- package/app/components/element/UiOnboardingAnswers.vue +19 -17
- package/app/components/element/UiSelect.vue +15 -2
- package/app/components/element/onboarding-answers.ts +25 -0
- package/app/plugins/favicon.ts +7 -21
- package/app/utils/app-favicon.ts +64 -0
- package/build/design-system.ts +23 -1
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/css/global.css +65 -61
- package/nuxt.config.ts +37 -26
- package/package.json +19 -12
- package/public/fonts/README.md +2 -0
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- 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
|
|
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+,
|
|
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
|
-
**
|
|
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
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
|
22
|
-
// layer passes its own
|
|
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
|
-
|
|
26
|
+
copy: copyOverrides = {},
|
|
26
27
|
} = defineProps<{
|
|
27
28
|
intents?: readonly OnboardingIntent[]
|
|
28
|
-
|
|
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:
|
|
37
|
-
{ key: 'siteCount', question:
|
|
38
|
-
{ key: 'seoExperience', question:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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
|
package/app/plugins/favicon.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/build/design-system.ts
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
--text-color-viz-
|
|
277
|
-
--text-color-viz-
|
|
278
|
-
--text-color-viz-
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
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-
|
|
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
|
-
/*
|
|
1743
|
-
|
|
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
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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
|
-
//
|
|
88
|
-
//
|
|
91
|
+
// CSS variable discovery cannot add another face, so avoid parsing every
|
|
92
|
+
// component style during each Vite build.
|
|
89
93
|
processCSSVariables: false,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
83
|
+
"tailwindcss": "^4.3.3",
|
|
84
|
+
"vue": "^3.5.43",
|
|
85
|
+
"zod": "^4.6.7"
|
|
79
86
|
},
|
|
80
87
|
"publishConfig": {
|
|
81
88
|
"access": "public"
|
package/public/fonts/README.md
CHANGED
|
@@ -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
|
package/public/favicon-dark.png
DELETED
|
Binary file
|
package/public/favicon-light.png
DELETED
|
Binary file
|
package/public/favicon.ico
DELETED
|
Binary file
|
package/public/icon-dark.png
DELETED
|
Binary file
|
package/public/icon.png
DELETED
|
Binary file
|
|
Binary file
|
package/public/touch-icon.png
DELETED
|
Binary file
|