@nuxtseo/design-system 0.0.0-stage → 0.1.0

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 (225) hide show
  1. package/README.md +101 -2
  2. package/app/app.config.ts +596 -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 +232 -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 +125 -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 +55 -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 +68 -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 +29 -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/chartAnnotations.ts +326 -0
  180. package/app/utils/countries.ts +337 -0
  181. package/app/utils/export-download.ts +100 -0
  182. package/app/utils/favicon-fallback.ts +12 -0
  183. package/app/utils/icon-color.ts +105 -0
  184. package/app/utils/number.ts +74 -0
  185. package/app/utils/render-link.ts +14 -0
  186. package/app/utils/slot-text.ts +18 -0
  187. package/app/utils/squircle.ts +154 -0
  188. package/app/utils/topEntityStack.ts +197 -0
  189. package/app/utils/vue.ts +57 -0
  190. package/build/design-system.ts +47 -0
  191. package/build/icon-server-bundle.ts +276 -0
  192. package/build/runtime/icon-server-bundle.ts +29 -0
  193. package/css/global.css +1892 -0
  194. package/nuxt.config.ts +132 -0
  195. package/package.json +86 -3
  196. package/public/brand-icon-dark.png +0 -0
  197. package/public/brand-icon-light.png +0 -0
  198. package/public/favicon-dark.png +0 -0
  199. package/public/favicon-light.png +0 -0
  200. package/public/favicon.ico +0 -0
  201. package/public/fonts/README.md +31 -0
  202. package/public/fonts/fira-code-OFL.txt +93 -0
  203. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  204. package/public/fonts/hubot-sans-OFL.txt +93 -0
  205. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  206. package/public/icon-dark.png +0 -0
  207. package/public/icon.png +0 -0
  208. package/public/touch-icon-dark.png +0 -0
  209. package/public/touch-icon.png +0 -0
  210. package/server/api/favicon.get.ts +122 -0
  211. package/server/utils/favicon-source.ts +51 -0
  212. package/shared/contracts/feedback.ts +42 -0
  213. package/shared/contracts/index.ts +1 -0
  214. package/shared/delivery-channels.ts +53 -0
  215. package/shared/icons/index.ts +143 -0
  216. package/shared/icons/registry.ts +347 -0
  217. package/shared/icons/ui-slots.ts +58 -0
  218. package/shared/markdown.ts +44 -0
  219. package/shared/motion.ts +120 -0
  220. package/shared/rangi.ts +60 -0
  221. package/shared/rich-text.ts +34 -0
  222. package/shared/ui-icons.ts +6 -0
  223. package/shared/utils/urls.ts +64 -0
  224. package/types/index.ts +1 -0
  225. package/types/ui.ts +339 -0
@@ -0,0 +1,150 @@
1
+ import type { UiIcon } from './ui-icons'
2
+ import { toWidgetFailure, WIDGET_FAILURE_COPY } from './widget-failure'
3
+
4
+ /**
5
+ * What `UiErrorState` is allowed to say, and where the words came from.
6
+ *
7
+ * The old prop was `string | { message: string }`. Both arms rendered their
8
+ * string, so a caught query error and a hand-written sentence were the same
9
+ * type. Six pro pages passed the caught error, and ofetch writes the request
10
+ * into `message`: `[GET] "/api/pro/v1/sites/<id>/search/status": 500 Internal
11
+ * Server Error`. That put an internal route and a site id on a customer's
12
+ * screen, and credentials have reached error strings the same way before.
13
+ *
14
+ * The tag now records provenance, which is the thing the two arms never agreed
15
+ * on. A `wire` value is a caught object. It is typed `unknown`, so no template
16
+ * can read a string off it; only the HTTP status code is read, and only to pick
17
+ * copy this file owns. A `copy` value is a sentence the caller wrote, which is
18
+ * safe to render because a person chose the words.
19
+ *
20
+ * A caller can no longer pass a caught error where copy is expected: the free
21
+ * tools keep rendering their own vetted copy through `errorCopy`, and a pro
22
+ * page must say `wireError` and accept classified copy instead.
23
+ */
24
+ export type ErrorStateSource
25
+ = | { readonly _tag: 'wire', readonly error: unknown }
26
+ | { readonly _tag: 'copy', readonly title: string, readonly detail?: string }
27
+ | {
28
+ readonly _tag: 'rate-limited'
29
+ readonly detail: string
30
+ readonly resetAt?: Date
31
+ readonly retryAfter?: number
32
+ }
33
+
34
+ /**
35
+ * Classify a caught value. The component reads its status code and nothing else.
36
+ *
37
+ * Pass the raw error. Wrapping it in `new Error(someMessage)` throws away the
38
+ * status code the classifier needs and buys no safety.
39
+ */
40
+ export function wireError(error: unknown): ErrorStateSource {
41
+ return { _tag: 'wire', error }
42
+ }
43
+
44
+ /**
45
+ * Render a sentence the caller wrote. Use it for copy a person authored.
46
+ *
47
+ * Never pass an error's `message` through here. Use `wireError` for a caught
48
+ * value, so the status code picks the copy.
49
+ */
50
+ export function errorCopy(title: string, detail?: string): ErrorStateSource {
51
+ return { _tag: 'copy', title, detail }
52
+ }
53
+
54
+ /**
55
+ * A quota refusal the caller already parsed, with the reset it read from the
56
+ * response headers. `wireError` covers a 429 too, but it cannot state a reset
57
+ * time, because a status code carries none.
58
+ */
59
+ export function rateLimited(
60
+ detail: string,
61
+ reset?: { resetAt?: Date, retryAfter?: number },
62
+ ): ErrorStateSource {
63
+ return { _tag: 'rate-limited', detail, resetAt: reset?.resetAt, retryAfter: reset?.retryAfter }
64
+ }
65
+
66
+ /**
67
+ * The next-step line under the message.
68
+ *
69
+ * Tagged because the two kinds behave differently: a `timing` line states a
70
+ * real reset the caller measured and always shows, while a `generic` line is
71
+ * filler that an `#action` slot replaces with something better.
72
+ */
73
+ export type ErrorStateNextStep
74
+ = | { readonly _tag: 'timing', readonly text: string }
75
+ | { readonly _tag: 'generic', readonly text: string }
76
+
77
+ /** Everything the template renders. No field holds a caught value. */
78
+ export interface ErrorStateView {
79
+ readonly tone: 'error' | 'warning'
80
+ readonly icon: UiIcon
81
+ /** What happened. */
82
+ readonly title: string
83
+ /** What to do about it, when the failure is specific enough to say. */
84
+ readonly detail: string | null
85
+ readonly nextStep: ErrorStateNextStep | null
86
+ /** Whether retrying the same request can succeed. */
87
+ readonly retryable: boolean
88
+ }
89
+
90
+ const GENERIC_NEXT_STEP = 'Try again in a moment. If it keeps happening, contact support.'
91
+
92
+ function resetHint(source: { resetAt?: Date, retryAfter?: number }, now: number): string | null {
93
+ if (source.retryAfter)
94
+ return `Try again in ${source.retryAfter} seconds`
95
+
96
+ if (!source.resetAt)
97
+ return null
98
+
99
+ const remaining = source.resetAt.getTime() - now
100
+ if (remaining <= 0)
101
+ return null
102
+ if (remaining < 60_000)
103
+ return `Resets in ${Math.ceil(remaining / 1000)} seconds`
104
+ if (remaining < 3_600_000)
105
+ return `Resets in ${Math.ceil(remaining / 60_000)} minutes`
106
+ return 'Resets at midnight UTC'
107
+ }
108
+
109
+ /**
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.
112
+ */
113
+ export function resolveErrorState(source: ErrorStateSource, now: number): ErrorStateView {
114
+ if (source._tag === 'copy') {
115
+ return {
116
+ tone: 'error',
117
+ icon: 'caution',
118
+ title: source.title,
119
+ detail: source.detail ?? null,
120
+ nextStep: { _tag: 'generic', text: GENERIC_NEXT_STEP },
121
+ retryable: true,
122
+ }
123
+ }
124
+
125
+ if (source._tag === 'rate-limited') {
126
+ const hint = resetHint(source, now)
127
+ return {
128
+ tone: 'warning',
129
+ icon: 'clock',
130
+ title: WIDGET_FAILURE_COPY['rate-limited'].title,
131
+ detail: source.detail,
132
+ nextStep: hint
133
+ ? { _tag: 'timing', text: hint }
134
+ : { _tag: 'generic', text: WIDGET_FAILURE_COPY['rate-limited'].body },
135
+ retryable: true,
136
+ }
137
+ }
138
+
139
+ const failure = toWidgetFailure(source.error)
140
+ const copy = WIDGET_FAILURE_COPY[failure._tag]
141
+ const limited = failure._tag === 'rate-limited'
142
+ return {
143
+ tone: limited ? 'warning' : 'error',
144
+ icon: limited ? 'clock' : 'caution',
145
+ title: copy.title,
146
+ detail: copy.body,
147
+ nextStep: null,
148
+ retryable: copy.retryable,
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export * from '../../shared/icons'
@@ -0,0 +1 @@
1
+ export * from '../../shared/motion'
@@ -0,0 +1,38 @@
1
+ export interface UiNavStability {
2
+ icon: string
3
+ tooltip: string
4
+ }
5
+
6
+ export interface UiNavSetup {
7
+ /**
8
+ * One short imperative word naming what the user owes this row — "Connect",
9
+ * "Confirm". Rendered verbatim in an outline chip, so anything longer than a
10
+ * single word is a layout bug; the full state goes in `tooltip`.
11
+ */
12
+ verb: string
13
+ tooltip: string
14
+ tone?: 'primary' | 'warning'
15
+ }
16
+
17
+ export interface UiNavLink {
18
+ label: string
19
+ to: string
20
+ icon?: string
21
+ disabled?: boolean
22
+ title?: string
23
+ badge?: string
24
+ badgeColor?: 'primary' | 'warning' | 'neutral'
25
+ setup?: UiNavSetup | null
26
+ stability?: UiNavStability | null
27
+ pending?: boolean
28
+ pendingTooltip?: string
29
+ /** Optional text-tone lift for a small subset of peer navigation items. */
30
+ textTone?: 'highlighted'
31
+ active?: (path: string) => boolean
32
+ /**
33
+ * Opt out of NuxtLink's idle route-chunk prefetch. Set `false` on links to
34
+ * explorer-sized routes (hundreds of KB) that sit in an always-visible nav —
35
+ * the sidebar would otherwise pull them on every page view.
36
+ */
37
+ prefetch?: boolean
38
+ }
@@ -0,0 +1,5 @@
1
+ import type { RouteLocationAsPathGeneric, RouteLocationAsRelative } from 'vue-router'
2
+
3
+ // Keep named routes and their params typed. URL paths stay open, like string URLs.
4
+ // Vue props cannot expand every generated path variant in a large RouteMap.
5
+ export type NavigationDestination = string | RouteLocationAsRelative | RouteLocationAsPathGeneric
@@ -0,0 +1,14 @@
1
+ export interface UiStaticTerminalLine {
2
+ /** The command without the shell prompt. */
3
+ command: string
4
+ /** Show a shell prompt. Set false for source code or configuration. */
5
+ prompt?: boolean
6
+ /** A shell comment shown before the command. */
7
+ note?: string
8
+ /** Static output shown after the command. */
9
+ output?: string
10
+ /** Rangi language for the command. */
11
+ language?: string
12
+ /** Rangi language for the output. */
13
+ outputLanguage?: string
14
+ }
@@ -0,0 +1,36 @@
1
+ import type { ExportColumn } from '../utils/export-download'
2
+
3
+ /** The part of a TanStack row an export reads. */
4
+ export interface ExportableRow {
5
+ getValue: <TValue>(columnId: string) => TValue
6
+ }
7
+
8
+ /** The part of a TanStack table an export reads. */
9
+ export interface ExportableTable<R extends ExportableRow> {
10
+ getPrePaginatedRowModel: () => { rows: R[] }
11
+ getVisibleLeafColumns: () => ReadonlyArray<{
12
+ id: string
13
+ accessorFn?: unknown
14
+ columnDef: { header?: unknown }
15
+ }>
16
+ }
17
+
18
+ /**
19
+ * Every row of a client-side table after its filters and sort, before paging,
20
+ * with one export column per visible data column. A display column with no
21
+ * accessor, such as row actions, is left out. A column whose header is not a
22
+ * plain string exports under its column id.
23
+ *
24
+ * Pagination is the only thing an export skips: a user who exports page 1 of 4
25
+ * expects the whole table.
26
+ */
27
+ export function uiTableExport<R extends ExportableRow>(table: ExportableTable<R>): { rows: R[], columns: ExportColumn<R>[] } {
28
+ const columns = table.getVisibleLeafColumns()
29
+ .filter(column => column.accessorFn !== undefined)
30
+ .map((column): ExportColumn<R> => ({
31
+ key: column.id,
32
+ header: typeof column.columnDef.header === 'string' && column.columnDef.header !== '' ? column.columnDef.header : column.id,
33
+ value: row => row.getValue(column.id),
34
+ }))
35
+ return { rows: table.getPrePaginatedRowModel().rows, columns }
36
+ }
@@ -0,0 +1,105 @@
1
+ import type { ColumnDef } from '@tanstack/table-core'
2
+ import {
3
+ columnFilteringFeature,
4
+ columnOrderingFeature,
5
+ columnVisibilityFeature,
6
+ createExpandedRowModel,
7
+ createFilteredRowModel,
8
+ createPaginatedRowModel,
9
+ createSortedRowModel,
10
+ rowExpandingFeature,
11
+ rowPaginationFeature,
12
+ rowSelectionFeature,
13
+ rowSortingFeature,
14
+ sortFn_alphanumeric,
15
+ sortFn_datetime,
16
+ sortFn_text,
17
+ tableFeatures,
18
+ } from '@tanstack/table-core'
19
+
20
+ export type UiTableSize = 'xs' | 'sm' | 'md'
21
+ export type UiTableAlign = 'left' | 'center' | 'right'
22
+
23
+ export const uiTableCellSizeClass: Record<UiTableSize, string> = {
24
+ xs: 'h-11 py-1 sm:h-8',
25
+ sm: 'h-11 py-1 sm:h-10',
26
+ md: 'h-11 py-2 sm:h-10',
27
+ }
28
+
29
+ export const uiTableSkeletonSizeClass: Record<UiTableSize, string> = {
30
+ xs: 'h-4',
31
+ sm: 'h-4',
32
+ md: 'h-6',
33
+ }
34
+
35
+ export type UiTableVisibleFrom = 'sm' | 'md' | 'lg' | 'xl' | '2xl'
36
+
37
+ export interface UiTableCellProps {
38
+ align?: UiTableAlign
39
+ numeric?: boolean
40
+ visibleFrom?: UiTableVisibleFrom
41
+ }
42
+
43
+ export interface UiTableColumnMeta extends UiTableCellProps {
44
+ noPadding?: boolean
45
+ stableData?: boolean
46
+ tooltip?: string
47
+ headClass?: string
48
+ cellClass?: string
49
+ rowHeader?: boolean
50
+ ui?: { td?: { base?: string } }
51
+ }
52
+
53
+ export const uiTableFeatures = tableFeatures({
54
+ columnFilteringFeature,
55
+ filteredRowModel: createFilteredRowModel(),
56
+ columnOrderingFeature,
57
+ columnVisibilityFeature,
58
+ rowExpandingFeature,
59
+ expandedRowModel: createExpandedRowModel(),
60
+ rowPaginationFeature,
61
+ paginatedRowModel: createPaginatedRowModel(),
62
+ rowSelectionFeature,
63
+ rowSortingFeature,
64
+ sortedRowModel: createSortedRowModel(),
65
+ // Auto sorting picks one of these names for a string or Date column. Left
66
+ // unregistered, it falls back to a plain `<` compare, so "Page2" sorts
67
+ // before "page1" and "page10" before "page2".
68
+ sortFns: {
69
+ alphanumeric: sortFn_alphanumeric,
70
+ datetime: sortFn_datetime,
71
+ text: sortFn_text,
72
+ },
73
+ })
74
+
75
+ export type UiTableFeatures = typeof uiTableFeatures
76
+ export type UiTableColumn<T extends object> = ColumnDef<UiTableFeatures, T, unknown> & UiTableColumnMeta
77
+ export type UiTableRowId<T> = string | ((row: T) => string)
78
+
79
+ /**
80
+ * A row's id: the `rowId` key or function, else the row's own `id`. A row with
81
+ * no usable id falls back to its index. Without that fallback every such row
82
+ * shared the id "", so selection, expansion and keys collided across rows. The
83
+ * fallback is prefixed: a bare index collided with a real id such as "1".
84
+ */
85
+ export function resolveUiTableRowId<T extends object>(row: T, index: number, rowId?: UiTableRowId<T>): string {
86
+ const record = row as Record<string, unknown>
87
+ const id = rowId === undefined
88
+ ? record.id
89
+ : typeof rowId === 'function'
90
+ ? rowId(row)
91
+ : record[rowId]
92
+ if (typeof id === 'string' && id !== '')
93
+ return id
94
+ if (typeof id === 'number')
95
+ return String(id)
96
+ return `__index:${index}`
97
+ }
98
+
99
+ export const uiTableVisibleFromClass: Record<UiTableVisibleFrom, string> = {
100
+ 'sm': 'hidden sm:table-cell',
101
+ 'md': 'hidden md:table-cell',
102
+ 'lg': 'hidden lg:table-cell',
103
+ 'xl': 'hidden xl:table-cell',
104
+ '2xl': 'hidden 2xl:table-cell',
105
+ }
@@ -0,0 +1 @@
1
+ export * from '../../shared/ui-icons'
@@ -0,0 +1,85 @@
1
+ /**
2
+ * What an async data region shows: one of four states, decided in one place.
3
+ *
4
+ * A region used to take `loading` and `items` and render its empty copy for
5
+ * anything else. A failed read left `items` empty, so the Search Console
6
+ * Overview told a user "No queries in this window" while the server returned a
7
+ * 503. A read that had not started yet did the same. The empty copy is a claim
8
+ * about the user's data, and only a successful answer can make it.
9
+ *
10
+ * So a region takes this union instead. `empty` exists only when a read
11
+ * succeeded and held nothing, and a template switches on `_tag`, so no branch
12
+ * can show the empty copy for a failure or for a read still in flight.
13
+ */
14
+ export type ViewState<T>
15
+ = | { readonly _tag: 'loading' }
16
+ | { readonly _tag: 'error', readonly error: unknown }
17
+ | { readonly _tag: 'empty' }
18
+ | {
19
+ readonly _tag: 'data'
20
+ readonly data: T
21
+ /**
22
+ * A later read failed while an earlier answer is still on screen. The
23
+ * region keeps the rows and says the refresh failed, so a failed refresh
24
+ * never throws away evidence and never passes for fresh data.
25
+ */
26
+ readonly refreshError: unknown
27
+ }
28
+
29
+ export type ViewStateTag = ViewState<unknown>['_tag']
30
+
31
+ export interface ViewStateInput<T> {
32
+ /** A read is in flight. */
33
+ readonly pending: boolean
34
+ /** The last read's failure. Any falsy value means it did not fail. */
35
+ readonly error: unknown
36
+ /**
37
+ * The last successful answer. `null` or `undefined` means no read has
38
+ * succeeded yet, which covers a read that has not started: on the server, or
39
+ * before its inputs resolve.
40
+ */
41
+ readonly data: T | null | undefined
42
+ /** Whether a successful answer holds nothing to show. */
43
+ readonly isEmpty: (data: T) => boolean
44
+ }
45
+
46
+ const LOADING: ViewState<never> = { _tag: 'loading' }
47
+ const EMPTY: ViewState<never> = { _tag: 'empty' }
48
+
49
+ /**
50
+ * Decide the state of a region. Pure.
51
+ *
52
+ * The order is the contract:
53
+ * 1. A read in flight is `loading`.
54
+ * 2. A failure with nothing to show is `error`, never `empty`.
55
+ * 3. No answer yet is `loading`, never `empty`.
56
+ * 4. A successful answer with nothing in it is `empty`.
57
+ * 5. Anything else is `data`, with the failure of a later read, if any.
58
+ */
59
+ export function viewState<T>(input: ViewStateInput<T>): ViewState<T> {
60
+ if (input.pending)
61
+ return LOADING
62
+ const failed = Boolean(input.error)
63
+ const answer = input.data
64
+ if (answer === null || answer === undefined)
65
+ return failed ? { _tag: 'error', error: input.error } : LOADING
66
+ if (input.isEmpty(answer))
67
+ return failed ? { _tag: 'error', error: input.error } : EMPTY
68
+ return { _tag: 'data', data: answer, refreshError: failed ? input.error : null }
69
+ }
70
+
71
+ /** `viewState` for a list: an answer with no rows is `empty`. */
72
+ export function listViewState<L extends readonly unknown[]>(input: Omit<ViewStateInput<L>, 'isEmpty'>): ViewState<L> {
73
+ return viewState<L>({ ...input, isEmpty: rows => rows.length === 0 })
74
+ }
75
+
76
+ /**
77
+ * Reshape the answer of a `data` state and keep every other state. A list that
78
+ * maps its rows for display, such as table rows built from query rows, keeps
79
+ * the state its read decided.
80
+ */
81
+ export function mapViewState<A, B>(state: ViewState<A>, map: (data: A) => B): ViewState<B> {
82
+ return state._tag === 'data'
83
+ ? { _tag: 'data', data: map(state.data), refreshError: state.refreshError }
84
+ : state
85
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Why an async region failed, in the only terms the UI is allowed to state.
3
+ *
4
+ * The tag is derived from an HTTP status code, which is a number. It is never
5
+ * derived from an error's `message`. `message` on a failed fetch is developer
6
+ * text that quotes the request: ofetch renders `[GET] "/api/pro/sites/42/…":
7
+ * 500 Internal Server Error`. Rendering that puts an internal route, a site id,
8
+ * and any query string on a customer's screen. Credentials have leaked this way
9
+ * before, so the widget layer holds no path from a wire string to the DOM.
10
+ *
11
+ * `unknown` covers every input this cannot place: a network drop, an abort, a
12
+ * response-schema mismatch, a plain `Error`, `null`. Those carry no status.
13
+ */
14
+ export type WidgetFailure
15
+ = | { readonly _tag: 'signed-out' }
16
+ | { readonly _tag: 'no-access' }
17
+ | { readonly _tag: 'missing' }
18
+ | { readonly _tag: 'rate-limited' }
19
+ | { readonly _tag: 'rejected' }
20
+ | { readonly _tag: 'server' }
21
+ | { readonly _tag: 'unknown' }
22
+
23
+ export type WidgetFailureTag = WidgetFailure['_tag']
24
+
25
+ export interface WidgetFailureCopy {
26
+ /** What happened. */
27
+ readonly title: string
28
+ /** What to do next. */
29
+ readonly body: string
30
+ /**
31
+ * Whether retrying the same read can succeed. A retry button on a 401 or a
32
+ * 403 is advice that cannot work, so those branches hide it.
33
+ */
34
+ readonly retryable: boolean
35
+ }
36
+
37
+ /**
38
+ * The copy for every failure the UI can show.
39
+ *
40
+ * `satisfies Record<WidgetFailureTag, WidgetFailureCopy>` is the guard: a new
41
+ * tag fails the typecheck here until someone writes its copy, so no failure can
42
+ * reach a customer without one.
43
+ */
44
+ export const WIDGET_FAILURE_COPY = {
45
+ 'signed-out': {
46
+ title: 'Your session ended',
47
+ body: 'Sign in again to load this data.',
48
+ retryable: false,
49
+ },
50
+ 'no-access': {
51
+ title: 'Access denied',
52
+ body: 'Ask a teammate to grant you access.',
53
+ retryable: false,
54
+ },
55
+ 'missing': {
56
+ title: 'Not found',
57
+ body: 'This data moved or was removed.',
58
+ retryable: false,
59
+ },
60
+ 'rate-limited': {
61
+ title: 'Too many requests',
62
+ body: 'Wait a moment, then retry.',
63
+ retryable: true,
64
+ },
65
+ 'rejected': {
66
+ title: 'The request was rejected',
67
+ body: 'Reload the page, then try again.',
68
+ retryable: true,
69
+ },
70
+ 'server': {
71
+ title: 'The server failed',
72
+ body: 'We logged this failure. Retry in a moment.',
73
+ retryable: true,
74
+ },
75
+ 'unknown': {
76
+ title: 'This data failed to load',
77
+ body: 'Check your connection, then retry.',
78
+ retryable: true,
79
+ },
80
+ } as const satisfies Record<WidgetFailureTag, WidgetFailureCopy>
81
+
82
+ /**
83
+ * HTTP status from any error shape.
84
+ *
85
+ * Twin of `queryErrorStatusCode` in `layers/core/app/utils/rpc.ts`. It is copied
86
+ * rather than imported because ADR-0042 lets `design-system` import `core` for
87
+ * types only. Both read the same four fields; keep them in step.
88
+ */
89
+ function statusCodeOf(error: unknown): number | undefined {
90
+ if (!error || typeof error !== 'object')
91
+ return undefined
92
+ const e = error as {
93
+ statusCode?: unknown
94
+ status?: unknown
95
+ data?: { statusCode?: unknown }
96
+ response?: { status?: unknown }
97
+ }
98
+ const candidate = [e.statusCode, e.status, e.data?.statusCode, e.response?.status]
99
+ .find(value => typeof value === 'number')
100
+ return typeof candidate === 'number' ? candidate : undefined
101
+ }
102
+
103
+ /**
104
+ * Parse an arbitrary caught value once, at the widget boundary, into the tag the
105
+ * template trusts. Everything inward of this function is safe to render.
106
+ */
107
+ export function toWidgetFailure(error: unknown): WidgetFailure {
108
+ const status = statusCodeOf(error)
109
+ if (status === 401)
110
+ return { _tag: 'signed-out' }
111
+ if (status === 403)
112
+ return { _tag: 'no-access' }
113
+ if (status === 404)
114
+ return { _tag: 'missing' }
115
+ if (status === 429)
116
+ return { _tag: 'rate-limited' }
117
+ if (status !== undefined && status >= 400 && status < 500)
118
+ return { _tag: 'rejected' }
119
+ if (status !== undefined && status >= 500)
120
+ return { _tag: 'server' }
121
+ return { _tag: 'unknown' }
122
+ }
123
+
124
+ export function widgetFailureCopy(error: unknown): WidgetFailureCopy {
125
+ return WIDGET_FAILURE_COPY[toWidgetFailure(error)._tag]
126
+ }