@nuxtseo/design-system 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -13
- package/app/components/container/UiAlert.vue +4 -2
- package/app/components/container/UiErrorState.vue +5 -3
- package/app/components/container/UiSectionHeader.vue +12 -2
- package/app/components/container/UiToast.vue +1 -37
- package/app/components/container/UiToaster.vue +1 -11
- package/app/components/container/UiWidgetState.vue +7 -5
- package/app/components/data/UiDataList.vue +6 -4
- package/app/components/data/UiMetricLabel.vue +3 -1
- package/app/components/data/UiStat.vue +1 -60
- package/app/components/data/UiStats.vue +1 -1
- package/app/components/data/UiTable.vue +16 -52
- package/app/components/data/UiTableTd.vue +9 -1
- package/app/components/data/UiTableTh.vue +10 -2
- package/app/components/data/cells/UiTableHeaderCell.vue +1 -1
- package/app/components/element/UiAtmosphere.vue +22 -4
- package/app/components/element/UiConfirmDialog.vue +54 -7
- package/app/components/element/UiOnboardingAnswers.vue +78 -43
- package/app/components/element/UiSkeleton.vue +50 -1
- package/app/components/element/UiTooltip.vue +7 -2
- package/app/components/element/UiTrend.vue +16 -8
- package/app/composables/design-system-config.ts +29 -0
- package/app/composables/useConfirm.ts +31 -3
- package/app/shared/error-state.ts +13 -12
- package/app/shared/failure-copy.ts +53 -0
- package/app/shared/stat.ts +65 -0
- package/app/shared/table.ts +74 -1
- package/app/shared/toast.ts +52 -0
- package/app/shared/widget-failure.ts +3 -2
- package/build/design-system.ts +48 -12
- package/css/global.css +46 -9
- package/nuxt.config.ts +2 -20
- package/package.json +6 -5
- package/build/fonts.ts +0 -39
- package/public/fonts/README.md +0 -33
- package/public/fonts/fira-code-OFL.txt +0 -93
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +0 -93
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { FailureCopy } from './failure-copy'
|
|
1
2
|
import type { UiIcon } from './ui-icons'
|
|
2
|
-
import {
|
|
3
|
+
import { FAILURE_COPY } from './failure-copy'
|
|
4
|
+
import { toWidgetFailure } from './widget-failure'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* What `UiErrorState` is allowed to say, and where the words came from.
|
|
@@ -87,8 +89,6 @@ export interface ErrorStateView {
|
|
|
87
89
|
readonly retryable: boolean
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
const GENERIC_NEXT_STEP = 'Try again in a moment. If it keeps happening, contact support.'
|
|
91
|
-
|
|
92
92
|
function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number): string | null {
|
|
93
93
|
if (source.retryAfter)
|
|
94
94
|
return `Try again in ${source.retryAfter} seconds`
|
|
@@ -108,16 +108,17 @@ function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number)
|
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
110
|
* Turn a source into the words on screen. Pure: pass the clock in, so the
|
|
111
|
-
* reset hint is testable and the component owns no time.
|
|
111
|
+
* reset hint is testable and the component owns no time. Pass `copy` to use a
|
|
112
|
+
* site's reworded failures (`readFailureCopy()`).
|
|
112
113
|
*/
|
|
113
|
-
export function resolveErrorState(source: ErrorStateSource, now: number): ErrorStateView {
|
|
114
|
+
export function resolveErrorState(source: ErrorStateSource, now: number, copy: FailureCopy = FAILURE_COPY): ErrorStateView {
|
|
114
115
|
if (source._tag === 'copy') {
|
|
115
116
|
return {
|
|
116
117
|
tone: 'error',
|
|
117
118
|
icon: 'caution',
|
|
118
119
|
title: source.title,
|
|
119
120
|
detail: source.detail ?? null,
|
|
120
|
-
nextStep: { _tag: 'generic', text:
|
|
121
|
+
nextStep: { _tag: 'generic', text: copy.nextStep },
|
|
121
122
|
retryable: true,
|
|
122
123
|
}
|
|
123
124
|
}
|
|
@@ -127,24 +128,24 @@ export function resolveErrorState(source: ErrorStateSource, now: number): ErrorS
|
|
|
127
128
|
return {
|
|
128
129
|
tone: 'warning',
|
|
129
130
|
icon: 'clock',
|
|
130
|
-
title:
|
|
131
|
+
title: copy.failures['rate-limited'].title,
|
|
131
132
|
detail: source.detail,
|
|
132
133
|
nextStep: hint
|
|
133
134
|
? { _tag: 'timing', text: hint }
|
|
134
|
-
: { _tag: 'generic', text:
|
|
135
|
+
: { _tag: 'generic', text: copy.failures['rate-limited'].body },
|
|
135
136
|
retryable: true,
|
|
136
137
|
}
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
const failure = toWidgetFailure(source.error)
|
|
140
|
-
const
|
|
141
|
+
const failureCopy = copy.failures[failure._tag]
|
|
141
142
|
const limited = failure._tag === 'rate-limited'
|
|
142
143
|
return {
|
|
143
144
|
tone: limited ? 'warning' : 'error',
|
|
144
145
|
icon: limited ? 'clock' : 'caution',
|
|
145
|
-
title:
|
|
146
|
-
detail:
|
|
146
|
+
title: failureCopy.title,
|
|
147
|
+
detail: failureCopy.body,
|
|
147
148
|
nextStep: null,
|
|
148
|
-
retryable:
|
|
149
|
+
retryable: failureCopy.retryable,
|
|
149
150
|
}
|
|
150
151
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WidgetFailureCopy, WidgetFailureTag } from './widget-failure'
|
|
2
|
+
import { WIDGET_FAILURE_COPY } from './widget-failure'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every string the failure surfaces render: `UiWidgetState`, `UiDataList` and
|
|
6
|
+
* `UiErrorState`. COPY.md (Shared primitive assets) owns the defaults. A site
|
|
7
|
+
* that extends the layer rewords them through `designSystem.failureCopy` in its
|
|
8
|
+
* app.config. Whether a failure is retryable is behaviour, so no override
|
|
9
|
+
* reaches it.
|
|
10
|
+
*/
|
|
11
|
+
export interface FailureCopy {
|
|
12
|
+
readonly failures: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>>
|
|
13
|
+
/** The retry button on `UiWidgetState` and `UiDataList`. */
|
|
14
|
+
readonly retryLabel: string
|
|
15
|
+
/** The retry button on `UiErrorState`. */
|
|
16
|
+
readonly errorStateRetryLabel: string
|
|
17
|
+
/** The next-step line `UiErrorState` shows under copy a caller wrote. */
|
|
18
|
+
readonly nextStep: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a site may reword. Each key it leaves out keeps the default. */
|
|
22
|
+
export interface FailureCopyOverrides {
|
|
23
|
+
readonly failures?: { readonly [K in WidgetFailureTag]?: Partial<Pick<WidgetFailureCopy, 'title' | 'body'>> }
|
|
24
|
+
readonly retryLabel?: string
|
|
25
|
+
readonly errorStateRetryLabel?: string
|
|
26
|
+
readonly nextStep?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const FAILURE_COPY: FailureCopy = {
|
|
30
|
+
failures: WIDGET_FAILURE_COPY,
|
|
31
|
+
retryLabel: 'Retry',
|
|
32
|
+
errorStateRetryLabel: 'Try again',
|
|
33
|
+
nextStep: 'Try again in a moment. If it keeps happening, contact support.',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FAILURE_TAGS = Object.keys(WIDGET_FAILURE_COPY) as WidgetFailureTag[]
|
|
37
|
+
|
|
38
|
+
/** Lay a site's overrides over the defaults. Pure, so a test passes its own. */
|
|
39
|
+
export function resolveFailureCopy(overrides: FailureCopyOverrides | undefined): FailureCopy {
|
|
40
|
+
if (!overrides)
|
|
41
|
+
return FAILURE_COPY
|
|
42
|
+
const failures = Object.fromEntries(FAILURE_TAGS.map((tag) => {
|
|
43
|
+
const base = FAILURE_COPY.failures[tag]
|
|
44
|
+
const own = overrides.failures?.[tag]
|
|
45
|
+
return [tag, { title: own?.title ?? base.title, body: own?.body ?? base.body, retryable: base.retryable }]
|
|
46
|
+
})) as Record<WidgetFailureTag, WidgetFailureCopy>
|
|
47
|
+
return {
|
|
48
|
+
failures,
|
|
49
|
+
retryLabel: overrides.retryLabel ?? FAILURE_COPY.retryLabel,
|
|
50
|
+
errorStateRetryLabel: overrides.errorStateRetryLabel ?? FAILURE_COPY.errorStateRetryLabel,
|
|
51
|
+
nextStep: overrides.nextStep ?? FAILURE_COPY.nextStep,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SlotTextOptions } from '../utils/slot-text'
|
|
2
|
+
import type { UiIcon as UiIconName } from './ui-icons'
|
|
3
|
+
|
|
4
|
+
// `UiStat`'s props. They live in this module, not the SFC, so a consumer can
|
|
5
|
+
// name them in its own `defineProps`: the Vue SFC compiler resolves a type
|
|
6
|
+
// from a package `.ts` file and not from a package `.vue` file.
|
|
7
|
+
|
|
8
|
+
type Datum = Record<string, number | string>
|
|
9
|
+
|
|
10
|
+
export interface UiStatProps {
|
|
11
|
+
// link
|
|
12
|
+
to?: string
|
|
13
|
+
|
|
14
|
+
// label
|
|
15
|
+
icon?: UiIconName
|
|
16
|
+
title?: string
|
|
17
|
+
tooltip?: string
|
|
18
|
+
tooltipDescription?: string
|
|
19
|
+
|
|
20
|
+
// value
|
|
21
|
+
value?: string | number | null
|
|
22
|
+
suffix?: string
|
|
23
|
+
valueClass?: string
|
|
24
|
+
format?: (n: number) => string
|
|
25
|
+
|
|
26
|
+
// trend
|
|
27
|
+
trend?: number | null
|
|
28
|
+
/**
|
|
29
|
+
* Formats the trend magnitude. The value `format` never applies: the trend is
|
|
30
|
+
* a percent or a delta, so a currency formatter printed "+$12.5%".
|
|
31
|
+
*/
|
|
32
|
+
trendFormat?: (n: number) => string
|
|
33
|
+
trendSuffix?: string
|
|
34
|
+
trendLabel?: string
|
|
35
|
+
invertTrend?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Trend color budget (DESIGN.md Composition): colored success/error trends
|
|
38
|
+
* belong to the hero zone only. Default renders neutral; `UiStats
|
|
39
|
+
* variant="cards"` (the hero pattern) opts its stats in automatically.
|
|
40
|
+
*/
|
|
41
|
+
trendColored?: boolean
|
|
42
|
+
|
|
43
|
+
// sparkline
|
|
44
|
+
sparkline?: Datum[]
|
|
45
|
+
|
|
46
|
+
// sizing
|
|
47
|
+
size?: 'sm' | 'md' | 'lg'
|
|
48
|
+
/** Keep report-visible labels and deltas at the 14px readable tier. */
|
|
49
|
+
readableLabels?: boolean
|
|
50
|
+
|
|
51
|
+
// state
|
|
52
|
+
loading?: boolean
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rolls compact metric values when they change. Defaults on for numeric-ish
|
|
56
|
+
* values; pass false for dense/realtime surfaces or object options to tune.
|
|
57
|
+
*/
|
|
58
|
+
animatedValue?: boolean | SlotTextOptions
|
|
59
|
+
|
|
60
|
+
// threshold alerting
|
|
61
|
+
status?: 'crisis' | 'warning' | 'good'
|
|
62
|
+
|
|
63
|
+
// render the value area inside a card, keeping the header outside
|
|
64
|
+
card?: boolean
|
|
65
|
+
}
|
package/app/shared/table.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColumnDef } from '@tanstack/table-core'
|
|
1
|
+
import type { Cell, ColumnDef, Row, RowSelectionState } from '@tanstack/table-core'
|
|
2
2
|
import {
|
|
3
3
|
columnFilteringFeature,
|
|
4
4
|
columnOrderingFeature,
|
|
@@ -103,3 +103,76 @@ export const uiTableVisibleFromClass: Record<UiTableVisibleFrom, string> = {
|
|
|
103
103
|
'xl': 'hidden xl:table-cell',
|
|
104
104
|
'2xl': 'hidden 2xl:table-cell',
|
|
105
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `UiTable`'s props. They live in this module, not the SFC, so a consumer that
|
|
109
|
+
* wraps the table can name them in its own `defineProps`. The Vue SFC compiler
|
|
110
|
+
* resolves a type from a package `.ts` file; it cannot resolve one from a
|
|
111
|
+
* package `.vue` file.
|
|
112
|
+
*/
|
|
113
|
+
export interface UiTableProps<T extends object> {
|
|
114
|
+
data: T[]
|
|
115
|
+
columns: UiTableColumn<T>[]
|
|
116
|
+
selected?: RowSelectionState
|
|
117
|
+
controlledSelection?: boolean
|
|
118
|
+
rowHover?: boolean
|
|
119
|
+
rowClickable?: boolean
|
|
120
|
+
enableSorting?: boolean
|
|
121
|
+
/** Caller owns sort state; UiTable emits @sortColumn and bypasses client-side sorting. */
|
|
122
|
+
manualSorting?: boolean
|
|
123
|
+
pageSize?: number
|
|
124
|
+
ignoreHeader?: boolean
|
|
125
|
+
size?: UiTableSize
|
|
126
|
+
loading?: boolean
|
|
127
|
+
loadingRows?: number
|
|
128
|
+
rowId?: UiTableRowId<T>
|
|
129
|
+
manualPagination?: boolean
|
|
130
|
+
disablePagination?: boolean
|
|
131
|
+
total?: number
|
|
132
|
+
/** Accessible name for the table. Rendered as a visually-hidden <caption>. */
|
|
133
|
+
label: string
|
|
134
|
+
/** Draw the canonical rounded table surface. */
|
|
135
|
+
bordered?: boolean
|
|
136
|
+
/** Pagination density. Compact renders previous, page position, and next only. */
|
|
137
|
+
paginationPreset?: 'default' | 'compact'
|
|
138
|
+
/** Fill the caller's available height and keep pagination pinned to the bottom. */
|
|
139
|
+
fill?: boolean
|
|
140
|
+
/** Extra classes applied per data row — e.g. a selection highlight. */
|
|
141
|
+
rowClass?: (row: T) => string
|
|
142
|
+
/** Row id presented as the active item in a click-through table. */
|
|
143
|
+
activeRowId?: string
|
|
144
|
+
/** Row id to open when the table first receives deep-linked evidence. */
|
|
145
|
+
initialExpandedRowId?: string
|
|
146
|
+
/**
|
|
147
|
+
* Show Export CSV and Export JSON, saving to this file name without the
|
|
148
|
+
* extension. The export holds every filtered and sorted row, not one page.
|
|
149
|
+
* Ignored with `manualPagination`: mount `UiTableExport` with every row.
|
|
150
|
+
*/
|
|
151
|
+
exportFilename?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What a `#<column id>-cell` slot receives: the TanStack row and cell, and the cell's value. */
|
|
155
|
+
export interface UiTableCellSlotProps<T extends object> {
|
|
156
|
+
row: Row<UiTableFeatures, T>
|
|
157
|
+
cell: Cell<UiTableFeatures, T, unknown>
|
|
158
|
+
value: unknown
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `UiTable`'s slots. A `#<column id>-cell` slot draws that column's cells and
|
|
163
|
+
* wins over the column's `cell` renderer. A column with no slot keeps its
|
|
164
|
+
* renderer. `#empty` replaces the zero-rows line.
|
|
165
|
+
*/
|
|
166
|
+
export type UiTableSlots<T extends object> = {
|
|
167
|
+
[K in `${string}-cell`]?: (props: UiTableCellSlotProps<T>) => unknown
|
|
168
|
+
} & {
|
|
169
|
+
'empty'?: () => unknown
|
|
170
|
+
'expanded-component'?: (props: { row: T }) => unknown
|
|
171
|
+
'actions'?: (props: { row: T }) => unknown
|
|
172
|
+
'tfoot'?: () => unknown
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The slot name that draws one column's cells. */
|
|
176
|
+
export function uiTableCellSlotName(columnId: string): `${string}-cell` {
|
|
177
|
+
return `${columnId}-cell`
|
|
178
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
import type { SemanticStatus } from '../composables/semanticColors'
|
|
3
|
+
|
|
4
|
+
// `UiToast` and `UiToaster` props. They live in this module, not the SFCs, so
|
|
5
|
+
// a consumer can name them in its own `defineProps`: the Vue SFC compiler
|
|
6
|
+
// resolves a type from a package `.ts` file and not from a package `.vue` file.
|
|
7
|
+
|
|
8
|
+
export interface ToastAction {
|
|
9
|
+
label: string
|
|
10
|
+
icon?: string
|
|
11
|
+
to?: string
|
|
12
|
+
onClick?: (e: MouseEvent) => void
|
|
13
|
+
/** Visual emphasis. `primary` = filled, `neutral` = quiet. */
|
|
14
|
+
color?: 'primary' | 'neutral'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UiToastProps {
|
|
18
|
+
/** Heading text (string or a render component). */
|
|
19
|
+
title?: string | Component
|
|
20
|
+
/** Body text (string or a render component). */
|
|
21
|
+
description?: string | Component
|
|
22
|
+
/** Semantic status — drives accent + default icon. Wins over `color`. */
|
|
23
|
+
status?: SemanticStatus
|
|
24
|
+
/** Nuxt UI color compatibility for existing `toast.add({ color })` call sites. */
|
|
25
|
+
color?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'error' | 'neutral'
|
|
26
|
+
/** Icon override. Defaults to the status icon. */
|
|
27
|
+
icon?: string
|
|
28
|
+
/** Inline action buttons. */
|
|
29
|
+
actions?: ToastAction[]
|
|
30
|
+
/**
|
|
31
|
+
* Bug-report handler. When set, failure toasts (error/warning) that carry no
|
|
32
|
+
* action of their own get a "Report bug" button. Wired by the app (the
|
|
33
|
+
* design system must not reach for the feedback drawer itself).
|
|
34
|
+
*/
|
|
35
|
+
reportBug?: (toast: { title?: string, description?: string }) => void
|
|
36
|
+
/** Show the close button. */
|
|
37
|
+
close?: boolean
|
|
38
|
+
/** Auto-dismiss progress 0..1 (remaining), or false to hide the bar. */
|
|
39
|
+
progress?: number | false
|
|
40
|
+
/** Collapsed back card — fade content so the deck reads as peeking cards. */
|
|
41
|
+
peek?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UiToasterProps {
|
|
45
|
+
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
46
|
+
/** Max cards rendered in the deck (older ones collapse into a "+N" pill). */
|
|
47
|
+
max?: number
|
|
48
|
+
/** Default auto-dismiss in ms; a per-toast `duration` overrides. 0 = sticky. */
|
|
49
|
+
duration?: number
|
|
50
|
+
/** Passed to every card: adds "Report bug" to failure toasts. See UiToast. */
|
|
51
|
+
reportBug?: UiToastProps['reportBug']
|
|
52
|
+
}
|
|
@@ -121,6 +121,7 @@ export function toWidgetFailure(error: unknown): WidgetFailure {
|
|
|
121
121
|
return { _tag: 'unknown' }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
/** The copy for a caught value. Pass `copy` to use a site's reworded failures. */
|
|
125
|
+
export function widgetFailureCopy(error: unknown, copy: Readonly<Record<WidgetFailureTag, WidgetFailureCopy>> = WIDGET_FAILURE_COPY): WidgetFailureCopy {
|
|
126
|
+
return copy[toWidgetFailure(error)._tag]
|
|
126
127
|
}
|
package/build/design-system.ts
CHANGED
|
@@ -1,7 +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 {
|
|
4
|
+
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { defineNuxtModule } from 'nuxt/kit'
|
|
6
|
+
import { dirname, resolve } from 'pathe'
|
|
5
7
|
import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
|
|
6
8
|
import { externalLinkMarkerCss } from './external-links'
|
|
7
9
|
|
|
@@ -12,13 +14,12 @@ export interface DesignSystemOptions {
|
|
|
12
14
|
*/
|
|
13
15
|
iconSet?: IconSet
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* when it installs, which can come before this module's setup.
|
|
17
|
+
* Keep the layer's composables and utils in the app's auto-import scan, and
|
|
18
|
+
* so in `#imports`. Defaults to false: their names, such as a one-argument
|
|
19
|
+
* `formatNumber`, would clash with a site's own helpers. A site imports
|
|
20
|
+
* them by path instead. Nuxt SEO's private `layers/brand` turns it on.
|
|
20
21
|
*/
|
|
21
|
-
|
|
22
|
+
autoImportUtils?: boolean
|
|
22
23
|
/**
|
|
23
24
|
* Hosts that count as this site. An absolute prose link to one of them gets
|
|
24
25
|
* no external-link arrow. Defaults to none, so every absolute http(s) link
|
|
@@ -59,11 +60,46 @@ export default defineNuxtModule<DesignSystemOptions>({
|
|
|
59
60
|
icon.clientBundle ??= {}
|
|
60
61
|
icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
|
|
61
62
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// A plain file, not a Nuxt template. In dev, Nuxt links every
|
|
64
|
+
// `nuxt.options.css` entry the module graph has not loaded, matching by
|
|
65
|
+
// file path. The graph holds a template as a `virtual:nuxt:` id with no
|
|
66
|
+
// such path, so a template stylesheet was linked twice, the second copy
|
|
67
|
+
// after the site's own CSS. The CLI clears the build directory after
|
|
68
|
+
// modules load, so the file is written when the build starts.
|
|
69
|
+
const externalLinks = resolve(nuxt.options.buildDir, 'design-system/external-links.css')
|
|
70
|
+
nuxt.options.css.push(externalLinks)
|
|
71
|
+
nuxt.hook('build:before', () => {
|
|
72
|
+
mkdirSync(dirname(externalLinks), { recursive: true })
|
|
73
|
+
writeFileSync(externalLinks, externalLinkMarkerCss(options.internalLinkHosts ?? []))
|
|
66
74
|
})
|
|
67
|
-
|
|
75
|
+
|
|
76
|
+
// The layer's templates pass `aria-*` attributes and a few listeners to
|
|
77
|
+
// components through $attrs, and `app/component-attributes.d.ts` types
|
|
78
|
+
// them. A consumer's tsconfig excludes node_modules, so the declaration
|
|
79
|
+
// never reached its program and a TypeScript 7 native typecheck failed
|
|
80
|
+
// inside the layer's templates. A reference ignores that exclude.
|
|
81
|
+
nuxt.hook('prepare:types', ({ references }) => {
|
|
82
|
+
references.push({ path: resolve(LAYER_ROOTS[0]!, 'app/component-attributes.d.ts') })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Nuxt scans every layer's composables/ and utils/ for auto-imports, and a
|
|
86
|
+
// layer's `imports.scan: false` would merge into the app and stop its scan
|
|
87
|
+
// too. So the layer removes only its own directories here.
|
|
88
|
+
if (!options.autoImportUtils) {
|
|
89
|
+
nuxt.hook('imports:dirs', (dirs) => {
|
|
90
|
+
const kept = dirs.filter(dir => !isLayerPath(dir))
|
|
91
|
+
dirs.splice(0, dirs.length, ...kept)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
68
94
|
},
|
|
69
95
|
})
|
|
96
|
+
|
|
97
|
+
const LAYER_ROOTS = [...new Set([resolve(import.meta.dirname, '..'), resolve(realpathSync(resolve(import.meta.dirname, '..')))])]
|
|
98
|
+
|
|
99
|
+
/** Whether a directory sits inside this layer, through a symlink or not. */
|
|
100
|
+
export function isLayerPath(dir: string): boolean {
|
|
101
|
+
const paths = [resolve(dir)]
|
|
102
|
+
if (existsSync(dir))
|
|
103
|
+
paths.push(resolve(realpathSync(dir)))
|
|
104
|
+
return paths.some(path => LAYER_ROOTS.some(root => path === root || path.startsWith(`${root}/`)))
|
|
105
|
+
}
|
package/css/global.css
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
/* Type families. A site sets its own in an unlayered `:root` rule; OG images
|
|
5
5
|
read them from the Tailwind entry instead (README, Brand seams). `--font-title`
|
|
6
6
|
carries page titles (`.text-title`, `font-title`) and defaults to the display
|
|
7
|
-
family. The
|
|
7
|
+
family. The layer registers no faces; a site loads its own (README). */
|
|
8
8
|
@theme {
|
|
9
9
|
--font-sans: 'Hubot Sans', sans-serif;
|
|
10
10
|
--font-display: 'Hubot Sans', sans-serif;
|
|
@@ -575,7 +575,13 @@
|
|
|
575
575
|
touch-action: manipulation;
|
|
576
576
|
}
|
|
577
577
|
|
|
578
|
-
.
|
|
578
|
+
/* Surface seam. The mode and dashboard blocks below sit in `:where()`, so
|
|
579
|
+
they carry no specificity: a site's plain `:root` rule sets any surface
|
|
580
|
+
token in both modes, and its `.dark` rule sets dark, whichever stylesheet
|
|
581
|
+
loads last. Nuxt UI's own defaults sit in `@layer theme` and still lose to
|
|
582
|
+
these. Source order decides between these blocks, so the dashboard blocks
|
|
583
|
+
stay after the mode blocks. */
|
|
584
|
+
:where(.light) {
|
|
579
585
|
color-scheme: light;
|
|
580
586
|
/* Bind surfaces to `--ui-color-neutral-*` (Nuxt UI's neutral alias → the
|
|
581
587
|
hue-292 slate override) rather than stock `--color-neutral-*` (cold hue
|
|
@@ -609,7 +615,7 @@
|
|
|
609
615
|
--nuxtseo-brand-tile-ink: oklch(98.4% 0.005 292);
|
|
610
616
|
}
|
|
611
617
|
|
|
612
|
-
.dark {
|
|
618
|
+
:where(.dark) {
|
|
613
619
|
color-scheme: dark;
|
|
614
620
|
/* Tier ladder in dark: 8.5 → 15 → 21 → 30. The old 7.5 → 13 gap read flat
|
|
615
621
|
on large surfaces; the new deltas give nav / body / elevated cards a
|
|
@@ -1193,7 +1199,7 @@ textarea {
|
|
|
1193
1199
|
/* Dashboard mauve palette: editorial monochrome tone reserved for /pro/dashboard.
|
|
1194
1200
|
Marketing/docs pages stay on the warm-slate neutral (hue 285). A site keeps
|
|
1195
1201
|
its own ramp here by setting `--ui-dashboard-neutral-<step>` (README). */
|
|
1196
|
-
.dashboard-theme {
|
|
1202
|
+
:where(.dashboard-theme) {
|
|
1197
1203
|
--ui-color-neutral-50: var(--ui-dashboard-neutral-50, var(--color-mauve-50, oklch(98.5% 0 0)));
|
|
1198
1204
|
--ui-color-neutral-100: var(--ui-dashboard-neutral-100, var(--color-mauve-100, oklch(96.7% 0.001 286.375)));
|
|
1199
1205
|
--ui-color-neutral-200: var(--ui-dashboard-neutral-200, var(--color-mauve-200, oklch(92% 0.004 286.32)));
|
|
@@ -1207,7 +1213,7 @@ textarea {
|
|
|
1207
1213
|
--ui-color-neutral-950: var(--ui-dashboard-neutral-950, var(--color-mauve-950, oklch(14.1% 0.005 285.823)));
|
|
1208
1214
|
}
|
|
1209
1215
|
|
|
1210
|
-
.light.dashboard-theme {
|
|
1216
|
+
:where(.light.dashboard-theme) {
|
|
1211
1217
|
background-color: var(--ui-bg);
|
|
1212
1218
|
--ui-bg: #fff;
|
|
1213
1219
|
--ui-bg-muted: var(--ui-color-neutral-50);
|
|
@@ -1226,7 +1232,7 @@ textarea {
|
|
|
1226
1232
|
--ui-border-inverted: var(--ui-color-neutral-900);
|
|
1227
1233
|
}
|
|
1228
1234
|
|
|
1229
|
-
.dark.dashboard-theme {
|
|
1235
|
+
:where(.dark.dashboard-theme) {
|
|
1230
1236
|
background-color: var(--ui-bg);
|
|
1231
1237
|
--color-mauve-50: oklch(98.5% 0.002 326);
|
|
1232
1238
|
--color-mauve-100: oklch(96.4% 0.005 325.6);
|
|
@@ -1356,7 +1362,7 @@ textarea {
|
|
|
1356
1362
|
mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
|
|
1357
1363
|
-webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 86%);
|
|
1358
1364
|
}
|
|
1359
|
-
.ui-atmosphere[data-geometry="bloom"] {
|
|
1365
|
+
.ui-atmosphere[data-geometry="bloom"][data-bloom-mask] {
|
|
1360
1366
|
mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
|
|
1361
1367
|
-webkit-mask-image: radial-gradient(58% 50% at 50% 38%, black, transparent 76%);
|
|
1362
1368
|
}
|
|
@@ -1366,7 +1372,8 @@ textarea {
|
|
|
1366
1372
|
}
|
|
1367
1373
|
/* veil = full scrim tint over a faded preview; no mask, softer by default */
|
|
1368
1374
|
|
|
1369
|
-
/* grain —
|
|
1375
|
+
/* grain — opt in through `grain` or `designSystem.atmosphere.grain`; static,
|
|
1376
|
+
soft-light so it reads as tooth, not dirt */
|
|
1370
1377
|
.ui-atmosphere[data-grain]::after {
|
|
1371
1378
|
content: "";
|
|
1372
1379
|
position: absolute;
|
|
@@ -1477,10 +1484,40 @@ textarea {
|
|
|
1477
1484
|
need primary/success/current keep overriding it (an unlayered rule would beat
|
|
1478
1485
|
the utility and silently force muted). */
|
|
1479
1486
|
@layer components {
|
|
1487
|
+
/* Type seam: a site sets `--ui-label-size`, `--ui-label-weight`,
|
|
1488
|
+
`--ui-label-case` and `--ui-label-tracking` at `:root` to retune every
|
|
1489
|
+
label at once (README, Brand seams). Unset, each keeps this spec. */
|
|
1480
1490
|
.text-label {
|
|
1481
|
-
|
|
1491
|
+
font-size: var(--ui-label-size, 10px);
|
|
1492
|
+
font-weight: var(--ui-label-weight, 600);
|
|
1493
|
+
text-transform: var(--ui-label-case, uppercase);
|
|
1494
|
+
letter-spacing: var(--ui-label-tracking, 0.05em);
|
|
1482
1495
|
color: var(--ui-text-muted);
|
|
1483
1496
|
}
|
|
1497
|
+
|
|
1498
|
+
/* Table type seam. Cells read `--ui-table-cell-size` and
|
|
1499
|
+
`--ui-table-cell-leading`; header labels read `--ui-table-head-size`, then
|
|
1500
|
+
the label size. Declared after `.text-label`, so a header carrying both
|
|
1501
|
+
takes the table size. Unset leading inherits, as the old rules did. */
|
|
1502
|
+
.ui-table-cell {
|
|
1503
|
+
font-size: var(--ui-table-cell-size, var(--text-xs));
|
|
1504
|
+
line-height: var(--ui-table-cell-leading, var(--text-xs--line-height));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
.ui-table-head {
|
|
1508
|
+
font-size: var(--ui-table-head-size, var(--ui-label-size, 10px));
|
|
1509
|
+
line-height: var(--ui-table-head-leading);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/* `UiTrend` sizes `xs`; `2xs` sits below, unlayered like `.text-mini`. */
|
|
1513
|
+
.ui-trend-xs {
|
|
1514
|
+
font-size: var(--ui-trend-xs-size, var(--text-xs));
|
|
1515
|
+
line-height: var(--ui-trend-xs-leading, var(--text-xs--line-height));
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
.ui-trend-2xs {
|
|
1520
|
+
font-size: var(--ui-trend-2xs-size, 10px);
|
|
1484
1521
|
}
|
|
1485
1522
|
|
|
1486
1523
|
/* Micro label — the 9px tier below .text-label, for the tiniest tags (table
|
package/nuxt.config.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import type { NuxtOptions } from 'nuxt/schema'
|
|
2
|
-
import type { DesignSystemOptions } from './build/design-system'
|
|
3
1
|
import { defineNuxtConfig } from 'nuxt/config'
|
|
4
|
-
import { useNuxt } from 'nuxt/kit'
|
|
5
2
|
import { join } from 'pathe'
|
|
6
|
-
import { withNuxtSeoFonts } from './build/fonts'
|
|
7
3
|
import { UI_ICON_SLOTS } from './shared/icons/ui-slots'
|
|
8
4
|
|
|
9
5
|
const currentDir = import.meta.dirname
|
|
@@ -84,28 +80,14 @@ export default defineNuxtConfig({
|
|
|
84
80
|
],
|
|
85
81
|
},
|
|
86
82
|
|
|
87
|
-
// The type stack is a brand seam. The layer
|
|
88
|
-
//
|
|
89
|
-
// registers them only for a site that sets `designSystem: { fonts: true }`.
|
|
83
|
+
// The type stack is a brand seam. The layer ships and registers no faces, so
|
|
84
|
+
// a site sets its own through `fonts.families` and the `--font-*` tokens.
|
|
90
85
|
fonts: {
|
|
91
86
|
// CSS variable discovery cannot add another face, so avoid parsing every
|
|
92
87
|
// component style during each Vite build.
|
|
93
88
|
processCSSVariables: false,
|
|
94
89
|
},
|
|
95
90
|
|
|
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
91
|
vite: {
|
|
110
92
|
optimizeDeps: {
|
|
111
93
|
// The layer's tables run @tanstack/vue-table 9, and @nuxt/ui's UTable
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nuxtseo/design-system",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "Nuxt SEO design system layer: UI primitives, tokens,
|
|
4
|
+
"version": "0.1.2",
|
|
5
|
+
"description": "Nuxt SEO design system layer: UI primitives, tokens, motion, and chart helpers. Extend it from a Nuxt app.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://nuxtseo.com",
|
|
8
8
|
"repository": {
|
|
@@ -37,13 +37,14 @@
|
|
|
37
37
|
"build",
|
|
38
38
|
"css",
|
|
39
39
|
"nuxt.config.ts",
|
|
40
|
-
"public",
|
|
41
40
|
"server",
|
|
42
41
|
"shared",
|
|
43
42
|
"types"
|
|
44
43
|
],
|
|
45
44
|
"peerDependencies": {
|
|
45
|
+
"@nuxt/scripts": "^1.3.10 || ^2.0.0-beta.14",
|
|
46
46
|
"@nuxt/ui": "^4.11.1",
|
|
47
|
+
"@vueuse/core": "^14.0.0 || ^15.0.0",
|
|
47
48
|
"motion-v": "^2.4.2",
|
|
48
49
|
"nuxt": "^4.6.0",
|
|
49
50
|
"tailwindcss": "^4.3.0",
|
|
@@ -62,19 +63,19 @@
|
|
|
62
63
|
"@nuxt/fonts": "^1.0.1",
|
|
63
64
|
"@nuxt/icon": "^2.5.1",
|
|
64
65
|
"@nuxt/image": "^2.1.0",
|
|
65
|
-
"@nuxt/scripts": "^2.0.0-beta.14",
|
|
66
66
|
"@tanstack/table-core": "^9.2.4",
|
|
67
67
|
"@tanstack/vue-table": "^9.2.4",
|
|
68
68
|
"@unovis/ts": "^1.6.7",
|
|
69
69
|
"@unovis/vue": "^1.6.7",
|
|
70
|
-
"@vueuse/core": "^14.0.0",
|
|
71
70
|
"nuxtseo-shared": "^6.0.2",
|
|
72
71
|
"pathe": "^2.0.3",
|
|
73
72
|
"rangi": "^2.2.0",
|
|
74
73
|
"reka-ui": "^2.10.4"
|
|
75
74
|
},
|
|
76
75
|
"devDependencies": {
|
|
76
|
+
"@nuxt/scripts": "^2.0.0-beta.14",
|
|
77
77
|
"@nuxt/ui": "^4.11.1",
|
|
78
|
+
"@vueuse/core": "^14.4.0",
|
|
78
79
|
"ai": "^7.0.122",
|
|
79
80
|
"motion-v": "2.4.2",
|
|
80
81
|
"nuxt": "4.6.1",
|
package/build/fonts.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
}
|