@nuxtseo/design-system 0.0.0-stage → 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.
Files changed (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,106 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import type { OnboardingAnswersDraft } from '../components/element/onboarding-answers'
3
+ import { inject, reactive } from 'vue'
4
+
5
+ /**
6
+ * The onboarding wizard's data layer as a swappable service, so the wizard +
7
+ * step components stay dumb (render state, call methods — no RPC). The brand-kit
8
+ * showcase drives the flow against `createInMemoryOnboardingService()`: it keeps
9
+ * its own reactive state and resolves everything locally, so the whole wizard
10
+ * runs e2e with zero network / no DB. The live app keeps its own RPC wiring; if
11
+ * it ever adopts this seam it provides an RPC-backed impl of the same interface.
12
+ *
13
+ * Pure data shapes + an in-memory impl — no runtime services — so it's safe in
14
+ * design-system and importable by brand-kit.
15
+ */
16
+ export interface OnboardingSite {
17
+ id: string
18
+ url: string
19
+ }
20
+
21
+ export interface OnboardingInvite {
22
+ email: string
23
+ role: string
24
+ }
25
+
26
+ export interface OnboardingState {
27
+ workspaceId: string | null
28
+ workspaceName: string | null
29
+ /** The "About you" Onboarding Answers. */
30
+ answers: OnboardingAnswersDraft
31
+ sites: OnboardingSite[]
32
+ connectedProviders: string[]
33
+ invites: OnboardingInvite[]
34
+ }
35
+
36
+ export interface OnboardingService {
37
+ readonly state: OnboardingState
38
+ createWorkspace: (name: string) => Promise<{ id: string }>
39
+ validateSite: (url: string) => Promise<{ valid: boolean, normalizedUrl?: string, error?: string }>
40
+ addSites: (urls: string[]) => Promise<{ ids: string[] }>
41
+ saveAnswers: (answers: OnboardingAnswersDraft) => Promise<void>
42
+ connectProvider: (provider: string, payload?: Record<string, unknown>) => Promise<void>
43
+ isConnected: (provider: string) => boolean
44
+ invite: (email: string, role: string) => Promise<void>
45
+ finish: () => Promise<void>
46
+ }
47
+
48
+ export const ONBOARDING_SERVICE: InjectionKey<OnboardingService> = Symbol('onboarding-service')
49
+
50
+ /** Injected service, or `null` when not inside an onboarding wizard. */
51
+ export function useOnboardingService(): OnboardingService | null {
52
+ return inject(ONBOARDING_SERVICE, null)
53
+ }
54
+
55
+ function emptyState(): OnboardingState {
56
+ return { workspaceId: null, workspaceName: null, answers: { intents: [], other: '' }, sites: [], connectedProviders: [], invites: [] }
57
+ }
58
+
59
+ /**
60
+ * In-memory implementation: mutates its own reactive state, no network. The
61
+ * counter (not Math.random) keeps ids stable across SSR/hydration.
62
+ */
63
+ export function createInMemoryOnboardingService(): OnboardingService {
64
+ const state = reactive<OnboardingState>(emptyState())
65
+ let seq = 0
66
+ const nextId = (prefix: string) => `${prefix}_mock_${++seq}`
67
+
68
+ return {
69
+ state,
70
+ async createWorkspace(name) {
71
+ state.workspaceName = name
72
+ state.workspaceId = nextId('team')
73
+ return { id: state.workspaceId }
74
+ },
75
+ async validateSite(url) {
76
+ const trimmed = url.trim()
77
+ if (!trimmed)
78
+ return { valid: false, error: 'Enter a URL' }
79
+ return { valid: true, normalizedUrl: /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}` }
80
+ },
81
+ async addSites(urls) {
82
+ const ids = urls.map((url) => {
83
+ const id = nextId('site')
84
+ state.sites.push({ id, url })
85
+ return id
86
+ })
87
+ return { ids }
88
+ },
89
+ async saveAnswers(answers) {
90
+ state.answers = { ...answers, intents: [...answers.intents] }
91
+ },
92
+ async connectProvider(provider) {
93
+ if (!state.connectedProviders.includes(provider))
94
+ state.connectedProviders.push(provider)
95
+ },
96
+ isConnected(provider) {
97
+ return state.connectedProviders.includes(provider)
98
+ },
99
+ async invite(email, role) {
100
+ state.invites.push({ email, role })
101
+ },
102
+ async finish() {
103
+ // no-op: the showcase flow ends in place rather than navigating away.
104
+ },
105
+ }
106
+ }
@@ -0,0 +1,19 @@
1
+ import type { Ref, WatchSource } from 'vue'
2
+ import { readonly, ref, watch } from 'vue'
3
+
4
+ /**
5
+ * True from the first time `open` reads true, and true after that.
6
+ *
7
+ * An open-on-demand overlay mounts its Lazy component behind this latch. Its
8
+ * chunk then stays out of the page's boot graph and the server render. After
9
+ * the first open the overlay stays mounted, so its leave transition plays and
10
+ * its own state survives a reopen.
11
+ */
12
+ export function useOpenedOnce(open: WatchSource<boolean>): Readonly<Ref<boolean>> {
13
+ const opened = ref(false)
14
+ watch(open, (value) => {
15
+ if (value)
16
+ opened.value = true
17
+ }, { immediate: true, flush: 'sync' })
18
+ return readonly(opened)
19
+ }
@@ -0,0 +1,178 @@
1
+ import type { MaybeRefOrGetter, Ref } from 'vue'
2
+ import { defaultWindow, useEventListener, useThrottleFn, useTimeoutFn } from '@vueuse/core'
3
+ import { nextTick, onMounted, ref, toValue } from 'vue'
4
+
5
+ // Define types for the sections
6
+ type SectionId = string
7
+
8
+ interface Section {
9
+ id: SectionId
10
+ label?: string
11
+
12
+ [key: string]: unknown
13
+ }
14
+
15
+ type SectionInput = SectionId | Section
16
+
17
+ interface ScrollSpyOptions {
18
+ offsetPx?: number
19
+ throttleMs?: number
20
+ }
21
+
22
+ // Return type of the composable
23
+ interface ScrollSpyReturn {
24
+ activeSection: Ref<SectionId>
25
+ scrollToSection: (sectionId: SectionId) => void
26
+ }
27
+
28
+ /**
29
+ * A composable for tracking which sections are currently visible in the viewport
30
+ * and providing navigation functionality.
31
+ *
32
+ * `sections` accepts a ref/getter so a dynamic section list (e.g. derived from
33
+ * async content) stays live — it is read via `toValue` on every recalculation
34
+ * rather than captured once at call time.
35
+ *
36
+ * @param sections The sections to track (reactive)
37
+ * @param options Configuration options
38
+ * @returns Active section and navigation methods
39
+ */
40
+ export function useScrollSpy(sections: MaybeRefOrGetter<SectionInput[]>, options: ScrollSpyOptions = {}): ScrollSpyReturn {
41
+ const {
42
+ offsetPx = -10, // Offset from the top (e.g., for fixed headers)
43
+ throttleMs = 100, // Throttle milliseconds
44
+ } = options
45
+
46
+ const activeSection = ref<SectionId>('')
47
+ const isUserScrolling = ref(true) // Assume initial scroll is from user
48
+
49
+ /**
50
+ * Get section ID from either string or object
51
+ */
52
+ function getSectionId(section: SectionInput): SectionId {
53
+ return typeof section === 'string' ? section : section.id
54
+ }
55
+
56
+ // Re-arm the "programmatic scroll done" flag after a smooth scroll settles.
57
+ // useTimeoutFn auto-cancels on scope dispose so it never fires into a torn-down instance.
58
+ const { start: startResetUserScrolling, stop: stopResetUserScrolling } = useTimeoutFn(() => {
59
+ isUserScrolling.value = true
60
+ }, 1000, { immediate: false })
61
+
62
+ /**
63
+ * Scrolls the page to the selected section
64
+ */
65
+ function scrollToSection(sectionId: SectionId): void {
66
+ // Set active section immediately on click
67
+ activeSection.value = sectionId
68
+
69
+ // Flag that next scrolls will be programmatic (not user-initiated)
70
+ isUserScrolling.value = false
71
+
72
+ const element = document.getElementById(sectionId)
73
+ if (element) {
74
+ // Get element position
75
+ const rect = element.getBoundingClientRect()
76
+ const absoluteTop = window.scrollY + rect.top - offsetPx
77
+
78
+ // Scroll smoothly, unless the user prefers reduced motion (then jump).
79
+ const prefersReduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
80
+ window.scrollTo({
81
+ top: absoluteTop,
82
+ behavior: prefersReduced ? 'auto' : 'smooth',
83
+ })
84
+
85
+ // Reset the flag after the smooth scroll is likely complete
86
+ stopResetUserScrolling()
87
+ startResetUserScrolling()
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Calculates which section should be active based on scroll position
93
+ */
94
+ function calculateActiveSection(): void {
95
+ // Only calculate if this is user scrolling, not programmatic
96
+ if (!isUserScrolling.value) {
97
+ return
98
+ }
99
+
100
+ const sectionList = toValue(sections)
101
+
102
+ // Get all sections and their positions
103
+ const sectionElements: { id: string, top: number }[] = []
104
+
105
+ sectionList.forEach((section) => {
106
+ const id = getSectionId(section)
107
+ const element = document.getElementById(id)
108
+ if (element) {
109
+ const rect = element.getBoundingClientRect()
110
+ sectionElements.push({
111
+ id,
112
+ top: rect.top,
113
+ })
114
+ }
115
+ })
116
+
117
+ // Sort sections by position (top to bottom)
118
+ sectionElements.sort((a, b) => a.top - b.top)
119
+
120
+ // Find the first section that is active
121
+ let activeSectionId: string | null
122
+
123
+ // First check sections that are in the viewport
124
+ const inViewport = sectionElements.filter(
125
+ section => section.top <= offsetPx && section.top > -100,
126
+ )
127
+
128
+ if (inViewport[0]) {
129
+ // If sections are in viewport, select the first one
130
+ activeSectionId = inViewport[0].id
131
+ }
132
+ else {
133
+ // Otherwise, select the last section that's above the viewport
134
+ const aboveViewport = sectionElements.filter(section => section.top <= 0)
135
+ activeSectionId = aboveViewport.at(-1)?.id ?? sectionElements[0]?.id ?? null
136
+ }
137
+
138
+ if (activeSectionId) {
139
+ activeSection.value = activeSectionId
140
+ }
141
+ else if (sectionList[0]) {
142
+ // Fallback to first section if nothing else is active
143
+ activeSection.value = getSectionId(sectionList[0])
144
+ }
145
+ }
146
+
147
+ // Throttled version of calculateActiveSection
148
+ const throttledCalculateActiveSection = useThrottleFn(calculateActiveSection, throttleMs)
149
+
150
+ // Handle user interaction with the page
151
+ function handleUserInteraction() {
152
+ isUserScrolling.value = true
153
+ }
154
+
155
+ // Bind listeners synchronously at setup so useEventListener registers its
156
+ // cleanup against the active effect scope (binding inside the async nextTick
157
+ // below would lose the scope and leak the listeners).
158
+ useEventListener(defaultWindow, 'scroll', throttledCalculateActiveSection, { passive: true })
159
+ useEventListener(defaultWindow, 'mousedown', handleUserInteraction, { passive: true })
160
+ useEventListener(defaultWindow, 'keydown', handleUserInteraction, { passive: true })
161
+ useEventListener(defaultWindow, 'touchstart', handleUserInteraction, { passive: true })
162
+
163
+ // Recalculate shortly after mount to catch dynamic content that shifts offsets.
164
+ const { start: startDeferredRecalc } = useTimeoutFn(calculateActiveSection, 300, { immediate: false })
165
+
166
+ onMounted(() => {
167
+ // Wait for DOM to be fully rendered before the first measurement
168
+ void nextTick(() => {
169
+ calculateActiveSection()
170
+ startDeferredRecalc()
171
+ })
172
+ })
173
+
174
+ return {
175
+ activeSection,
176
+ scrollToSection,
177
+ }
178
+ }
@@ -0,0 +1,21 @@
1
+ import { onMounted, ref } from 'vue'
2
+
3
+ /**
4
+ * Squircle fallback gate. Native CSS `corner-shape: squircle` (Chrome 139+)
5
+ * renders the squircle at SSR with a native border and zero hydration morph
6
+ * (see the `@supports` rule in global.css), so it needs no JS. Only where
7
+ * `corner-shape` is unsupported (Safari/Firefox) do consumers fall back to a
8
+ * generated `clip-path` + SVG-border overlay.
9
+ *
10
+ * `enabled` stays `false` on the server and through hydration (SSR can't
11
+ * feature-detect), then flips to `true` after mount on non-supporting
12
+ * browsers — so the fallback is a post-hydration enhancement, never a
13
+ * hydration mismatch.
14
+ */
15
+ export function useSquircleFallback() {
16
+ const enabled = ref(false)
17
+ onMounted(() => {
18
+ enabled.value = !(typeof CSS !== 'undefined' && CSS.supports?.('corner-shape', 'squircle'))
19
+ })
20
+ return { enabled }
21
+ }
@@ -0,0 +1,123 @@
1
+ import type { MaybeRefOrGetter, Ref, WritableComputedRef } from 'vue'
2
+ import type { LocationQuery } from 'vue-router'
3
+ import { navigateTo, useRoute, useRouter } from 'nuxt/app'
4
+ import { computed, onMounted, onScopeDispose, toValue, watch } from 'vue'
5
+ import { useOpenedOnce } from './useOpenedOnce'
6
+
7
+ export interface WizardRoute {
8
+ /** True while `?setup=<flowId>` is in the query and the flow is allowed. False strips `setup` and `step`; true restores the last step. */
9
+ open: WritableComputedRef<boolean>
10
+ /** `?step=<id>` while this flow is open. Set while closed, it is the step the next open starts at. */
11
+ step: WritableComputedRef<string | undefined>
12
+ /** True from the first open. Mount the Lazy flow behind it. */
13
+ opened: Readonly<Ref<boolean>>
14
+ /** The current path and query, plus `setup=<flowId>&step=<stepId>`. An OAuth flow returns here. */
15
+ returnTo: (stepId: string) => string
16
+ }
17
+
18
+ const FLOW_KEYS = ['setup', 'step'] as const
19
+ const HISTORY_MARK = 'setupFlow'
20
+
21
+ function withoutFlow(query: LocationQuery): LocationQuery {
22
+ const rest = { ...query }
23
+ for (const key of FLOW_KEYS)
24
+ delete rest[key]
25
+ return rest
26
+ }
27
+
28
+ /**
29
+ * Backs a `UiWizardModal` with the query: `?setup=<flowId>&step=<id>`.
30
+ *
31
+ * A link, a notification, or an OAuth return can then open a flow at a step.
32
+ * An open from the page pushes one marked history entry, so Back closes the
33
+ * flow, on a phone too. A close goes back over a marked entry. Step changes
34
+ * replace it, so stepping through a flow adds no history entries.
35
+ *
36
+ * `allowed` gates write flows. If it is false, the flow never opens and an
37
+ * incoming `?setup=<flowId>` is stripped. If it is undefined, the answer is
38
+ * still loading: the flow stays closed and the deep link stays in the query.
39
+ */
40
+ export function useWizardRoute(flowId: string, options: { allowed?: MaybeRefOrGetter<boolean | undefined> } = {}): WizardRoute {
41
+ const route = useRoute()
42
+ const router = useRouter()
43
+
44
+ const allowed = computed(() => 'allowed' in options ? toValue(options.allowed) : true)
45
+ const requested = computed(() => route.query.setup === flowId)
46
+
47
+ // An open from the page marks its history entry in history.state. A close
48
+ // then goes back over it, also after a reload or Forward. A deep link opens
49
+ // without the mark, and closes with a replace.
50
+ const onPushedEntry = () => (router.options.history.state as Record<string, unknown> | null)?.[HISTORY_MARK] === flowId
51
+ function replaceQuery(query: LocationQuery): void {
52
+ // A step change keeps the mark on the entry it replaces.
53
+ const state = onPushedEntry() ? { [HISTORY_MARK]: flowId } : undefined
54
+ void navigateTo({ query, hash: route.hash, state }, { replace: true })
55
+ }
56
+
57
+ // True from a close until a navigation settles. A second close in that time,
58
+ // such as Esc after Done, is dropped, so it cannot go back twice.
59
+ let closing = false
60
+ onScopeDispose(router.afterEach(() => {
61
+ closing = false
62
+ }))
63
+ // The step a reopen resumes, beside the form state the modal kept.
64
+ let lastStep: string | undefined
65
+ const open = computed<boolean>({
66
+ get: () => requested.value && allowed.value === true,
67
+ set: (value) => {
68
+ if (value === open.value || (value && allowed.value !== true))
69
+ return
70
+ if (!value) {
71
+ if (closing)
72
+ return
73
+ closing = true
74
+ if (onPushedEntry())
75
+ router.back()
76
+ else
77
+ replaceQuery(withoutFlow(route.query))
78
+ return
79
+ }
80
+ void navigateTo({
81
+ query: { ...withoutFlow(route.query), setup: flowId, ...(lastStep ? { step: lastStep } : {}) },
82
+ hash: route.hash,
83
+ state: { [HISTORY_MARK]: flowId },
84
+ })
85
+ },
86
+ })
87
+
88
+ const step = computed<string | undefined>({
89
+ get: () => open.value && typeof route.query.step === 'string' ? route.query.step : undefined,
90
+ set: (id) => {
91
+ if (!open.value) {
92
+ lastStep = id
93
+ return
94
+ }
95
+ if (id === step.value)
96
+ return
97
+ const { step: _step, ...rest } = route.query
98
+ replaceQuery(id === undefined ? rest : { ...rest, step: id })
99
+ },
100
+ })
101
+ // Every way out, the Close button, Back, or a link, keeps the step the user
102
+ // was on.
103
+ watch(step, (id) => {
104
+ if (id !== undefined)
105
+ lastStep = id
106
+ }, { immediate: true, flush: 'sync' })
107
+ // A denied deep link leaves the query, so a reload or a shared URL cannot
108
+ // open it. On the client only: a server-side replace would redirect.
109
+ const denied = computed(() => requested.value && allowed.value === false)
110
+ const strip = () => {
111
+ if (denied.value)
112
+ replaceQuery(withoutFlow(route.query))
113
+ }
114
+ onMounted(strip)
115
+ watch(denied, strip)
116
+
117
+ return {
118
+ open,
119
+ step,
120
+ opened: useOpenedOnce(open),
121
+ returnTo: stepId => router.resolve({ path: route.path, query: { ...withoutFlow(route.query), setup: flowId, step: stepId } }).fullPath,
122
+ }
123
+ }
@@ -0,0 +1,15 @@
1
+ import { defineNuxtPlugin, useAppConfig, useHead } from 'nuxt/app'
2
+ import { useColorMode } from '#imports'
3
+ import { appFaviconLinks } from '../utils/app-favicon'
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.
8
+ export default defineNuxtPlugin(() => {
9
+ const { favicon } = useAppConfig()
10
+ const colorMode = useColorMode()
11
+
12
+ useHead(() => ({
13
+ link: appFaviconLinks(favicon, colorMode.value === 'dark'),
14
+ }))
15
+ })
@@ -0,0 +1,22 @@
1
+ import { domAnimation, LazyMotion } from 'motion-v'
2
+ import { defineNuxtPlugin } from 'nuxt/app'
3
+
4
+ /**
5
+ * Animation features for every `m.*` component the layer renders, so an app
6
+ * that extends the layer needs no `<LazyMotion>` wrapper of its own.
7
+ *
8
+ * `vue:setup` runs inside the setup of NuxtRoot, the component above both
9
+ * `app.vue` and `error.vue`. Running LazyMotion's own setup there provides its
10
+ * features context from that component, as the wrapper would. motion-v does not
11
+ * export the context key, so the component's setup is the public way to
12
+ * provide it. A nested `<LazyMotion :features="domMax">` still overrides it for
13
+ * its subtree.
14
+ */
15
+ export default defineNuxtPlugin({
16
+ name: 'design-system:lazy-motion',
17
+ setup(nuxtApp) {
18
+ nuxtApp.hook('vue:setup', () => {
19
+ LazyMotion.setup!({ features: domAnimation, strict: false }, { slots: {}, attrs: {}, emit: () => {}, expose: () => {} } as never)
20
+ })
21
+ },
22
+ })
@@ -0,0 +1,176 @@
1
+ import { useEventListener } from '@vueuse/core'
2
+ import { defineNuxtPlugin } from 'nuxt/app'
3
+
4
+ /**
5
+ * Highlighter text selection. Replaces the native highlight bar with a hand-drawn
6
+ * violet marker swipe. `::selection` can't do gradients / slanted ends / rough
7
+ * edges, so instead of styling the selection we paint an OVERLAY of `.mark` divs
8
+ * over the selection's per-line client-rects (`global.css` blanks the native
9
+ * highlight, gated on the `selection-hl-ready` class this plugin adds).
10
+ *
11
+ * Unlike a wrap-the-text-nodes approach, the overlay never mutates the document:
12
+ * the real selection, copy, keyboard extension and live Vue-rendered surfaces are
13
+ * all untouched, and the marker paints live during the drag. Marks are grouped
14
+ * into per-theme isolated sub-layers so a selection spanning a light→dark boundary
15
+ * blends correctly on both sides, and overlapping line-marks (which close the
16
+ * vertical gaps) never double-darken.
17
+ */
18
+ export default defineNuxtPlugin(() => {
19
+ // Reduced motion: leave the native selection fully intact (no class, no overlay).
20
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
21
+ return
22
+
23
+ // Regions the overlay must skip: form controls + editable text (no stable text
24
+ // rects / shouldn't be marked) and code (keeps its own rangi styling). These
25
+ // keep the native highlight via the re-assert rules in global.css.
26
+ const EXCLUDE = 'input, textarea, [contenteditable], pre, code, .rangi'
27
+ const OX = 5 // horizontal overshoot (px) each side, so the swipe reads hand-drawn
28
+ const HFRAC = 1.06 // mark height vs line-box: >1 so adjacent lines meet
29
+ const TOPFRAC = -0.03 // nudge up so marks overlap and close the white gaps
30
+
31
+ const html = document.documentElement
32
+ html.classList.add('selection-hl-ready')
33
+
34
+ // The rough-edge filter can't live in CSS — inject it once, hidden.
35
+ const defs = document.createElement('div')
36
+ defs.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden'
37
+ defs.setAttribute('aria-hidden', 'true')
38
+ defs.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg">'
39
+ + '<filter id="selection-hl-rough" x="-15%" y="-60%" width="130%" height="220%" color-interpolation-filters="sRGB">'
40
+ + '<feTurbulence type="fractalNoise" baseFrequency="0.018 0.13" numOctaves="2" seed="7" result="n"/>'
41
+ + '<feDisplacementMap in="SourceGraphic" in2="n" scale="3" xChannelSelector="R" yChannelSelector="G"/>'
42
+ + '</filter></svg>'
43
+ document.body.appendChild(defs)
44
+
45
+ const layer = document.createElement('div')
46
+ layer.id = 'selection-hl-layer'
47
+ document.body.appendChild(layer)
48
+
49
+ let frame = 0
50
+ let settle = 0
51
+
52
+ // Deterministic per-line tilt so the marks don't reshuffle every repaint.
53
+ const tilt = (i: number): number => ((Math.sin(i * 12.9898) * 43758.5453) % 1) * 0.7 - 0.35
54
+
55
+ function isExcluded(node: Node | null): boolean {
56
+ const el = node instanceof Element ? node : node?.parentElement ?? null
57
+ return !!el?.closest(EXCLUDE)
58
+ }
59
+
60
+ interface LineRect { left: number, right: number, top: number, bottom: number, ctx: Element | null }
61
+
62
+ /**
63
+ * One merged rect per line of selected text (never block/margin rects), each
64
+ * tagged with the element it sits over so it can be themed correctly.
65
+ */
66
+ function lineRects(range: Range): LineRect[] {
67
+ const scope = range.commonAncestorContainer
68
+ const root = scope.nodeType === Node.TEXT_NODE ? scope.parentNode! : scope
69
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
70
+ acceptNode(node) {
71
+ return (!node.nodeValue?.trim() || isExcluded(node) || !range.intersectsNode(node))
72
+ ? NodeFilter.FILTER_REJECT
73
+ : NodeFilter.FILTER_ACCEPT
74
+ },
75
+ })
76
+ const lines = new Map<number, LineRect>()
77
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
78
+ const text = n as Text
79
+ const start = text === range.startContainer ? range.startOffset : 0
80
+ const end = text === range.endContainer ? range.endOffset : text.length
81
+ if (end <= start)
82
+ continue
83
+ const sub = document.createRange()
84
+ sub.setStart(text, start)
85
+ sub.setEnd(text, end)
86
+ for (const r of sub.getClientRects()) {
87
+ if (r.width < 1 || r.height < 2)
88
+ continue
89
+ const key = Math.round((r.top + r.bottom) / 2 / 3)
90
+ const cur = lines.get(key)
91
+ if (!cur) {
92
+ lines.set(key, { left: r.left, right: r.right, top: r.top, bottom: r.bottom, ctx: text.parentElement })
93
+ }
94
+ else {
95
+ cur.left = Math.min(cur.left, r.left)
96
+ cur.right = Math.max(cur.right, r.right)
97
+ cur.top = Math.min(cur.top, r.top)
98
+ cur.bottom = Math.max(cur.bottom, r.bottom)
99
+ }
100
+ }
101
+ }
102
+ return [...lines.values()]
103
+ }
104
+
105
+ interface Group { blend: string, op: string, hl: string, hl2: string, pool: string, streak: string, lines: LineRect[] }
106
+
107
+ function paint(): void {
108
+ layer.replaceChildren()
109
+ const sel = window.getSelection()
110
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0)
111
+ return
112
+ const range = sel.getRangeAt(0)
113
+ if (isExcluded(range.commonAncestorContainer))
114
+ return
115
+
116
+ // Theme each line by the element it sits over, then group same-theme lines
117
+ // into one isolated, blended sub-layer.
118
+ const groups = new Map<string, Group>()
119
+ for (const ln of lineRects(range)) {
120
+ const cs = getComputedStyle(ln.ctx ?? html)
121
+ const blend = cs.getPropertyValue('--selection-blend').trim() || 'multiply'
122
+ const op = cs.getPropertyValue('--selection-op').trim() || '0.56'
123
+ const hl = cs.getPropertyValue('--selection-hl').trim()
124
+ const hl2 = cs.getPropertyValue('--selection-hl2').trim() || hl
125
+ const pool = cs.getPropertyValue('--selection-pool').trim()
126
+ const streak = cs.getPropertyValue('--selection-streak').trim()
127
+ const sig = `${blend}|${op}|${hl}|${hl2}|${pool}|${streak}`
128
+ let g = groups.get(sig)
129
+ if (!g) {
130
+ g = { blend, op, hl, hl2, pool, streak, lines: [] }
131
+ groups.set(sig, g)
132
+ }
133
+ g.lines.push(ln)
134
+ }
135
+
136
+ for (const g of groups.values()) {
137
+ const cont = document.createElement('div')
138
+ cont.className = 'group'
139
+ cont.style.mixBlendMode = g.blend
140
+ cont.style.opacity = g.op
141
+ let i = 0
142
+ for (const r of g.lines) {
143
+ const rh = r.bottom - r.top
144
+ const rw = r.right - r.left
145
+ const d = document.createElement('div')
146
+ d.className = 'mark'
147
+ d.style.setProperty('--mk-hl', g.hl)
148
+ d.style.setProperty('--mk-hl2', g.hl2)
149
+ d.style.setProperty('--mk-pool', g.pool)
150
+ d.style.setProperty('--mk-streak', g.streak)
151
+ d.style.left = `${r.left - OX}px`
152
+ d.style.top = `${r.top + rh * TOPFRAC}px`
153
+ d.style.width = `${rw + OX * 2}px`
154
+ d.style.height = `${rh * HFRAC}px`
155
+ d.style.transform = `rotate(${tilt(i)}deg)`
156
+ cont.appendChild(d)
157
+ i++
158
+ }
159
+ layer.appendChild(cont)
160
+ }
161
+ }
162
+
163
+ function schedule(): void {
164
+ cancelAnimationFrame(frame)
165
+ frame = requestAnimationFrame(paint)
166
+ // Smooth (no rough filter) while the selection is actively changing; snap the
167
+ // hand-drawn edge back in once it's been still for a beat.
168
+ layer.classList.remove('rough')
169
+ clearTimeout(settle)
170
+ settle = window.setTimeout(() => layer.classList.add('rough'), 150)
171
+ }
172
+
173
+ useEventListener(document, 'selectionchange', schedule)
174
+ useEventListener(window, 'scroll', schedule, { capture: true, passive: true })
175
+ useEventListener(window, 'resize', schedule)
176
+ })
@@ -0,0 +1,10 @@
1
+ /**
2
+ * How a printed command invokes the CLI on a machine that may not have it.
3
+ *
4
+ * The package is `@nuxtseo/cli` (bin `nuxtseo`); nothing is published as
5
+ * `nuxtseo`, so `npx nuxtseo` is a 404. Every surface that prints a command
6
+ * with no install step above it spells the invocation this way so the line
7
+ * runs as written. A quick start that already installed the CLI may print the
8
+ * bare `nuxtseo` on the lines after the install.
9
+ */
10
+ export const CLI_INVOCATION = 'npx @nuxtseo/cli'