@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,63 @@
1
+ <script setup lang="ts">
2
+ import type { BrandProduct } from 'nuxtseo-shared/brand'
3
+ import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, markDrawing } from 'nuxtseo-shared/brand'
4
+ import { computed, useId } from 'vue'
5
+
6
+ /**
7
+ * The summit: the pen climbs a foothill, then the Nuxt peak, and lands on the
8
+ * apex. Free lands on a green dot. Pro climbs in a violet gradient and lands on
9
+ * a violet dot. Ink follows the text colour. `animate` draws the climb once,
10
+ * then lands the dot.
11
+ */
12
+ const { product = 'free', size = 28, palette = 'css', animate = false } = defineProps<{
13
+ product?: BrandProduct
14
+ /** Rendered size in px. 18 and below uses the heavier small cut. */
15
+ size?: number
16
+ palette?: 'css' | 'light' | 'dark'
17
+ animate?: boolean
18
+ }>()
19
+
20
+ // A page can show several marks, some hidden; each needs its own gradient id.
21
+ const id = useId()
22
+ const svg = computed(() => {
23
+ const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
24
+ return drawingToSvg(markDrawing({ product, size }), { palette: brandPalette, idPrefix: `brand-mark-${id}`, width: size, height: size, attrs: 'aria-hidden="true"' })
25
+ })
26
+ </script>
27
+
28
+ <template>
29
+ <!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
30
+ <span
31
+ data-ui="UiBrandMark"
32
+ class="ui-brand-mark inline-flex shrink-0 leading-none"
33
+ :class="{ 'ui-brand-mark--draw': animate }"
34
+ role="img"
35
+ :aria-label="product === 'pro' ? 'Nuxt SEO Pro' : 'Nuxt SEO'"
36
+ v-html="svg"
37
+ />
38
+ </template>
39
+
40
+ <style scoped>
41
+ /* The climb is the second path (after the shadowed flank); the landing dot is the last circle. */
42
+ @media (prefers-reduced-motion: no-preference) {
43
+ .ui-brand-mark--draw :deep(path:nth-of-type(2)) {
44
+ stroke-dasharray: 100;
45
+ stroke-dashoffset: 100;
46
+ animation: ui-brand-mark-draw 0.8s var(--ease-standard, ease-out) forwards;
47
+ }
48
+ .ui-brand-mark--draw :deep(circle:last-of-type) {
49
+ transform-box: fill-box;
50
+ transform-origin: center;
51
+ animation: ui-brand-mark-land 0.45s var(--ease-spring, ease-out) 0.7s both;
52
+ }
53
+ }
54
+
55
+ @keyframes ui-brand-mark-draw {
56
+ to { stroke-dashoffset: 0; }
57
+ }
58
+
59
+ @keyframes ui-brand-mark-land {
60
+ from { transform: scale(0); }
61
+ to { transform: scale(1); }
62
+ }
63
+ </style>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import type { BrandLanding } from 'nuxtseo-shared/brand'
3
+ import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, iconDrawing, isBrandIconName, markDrawing } from 'nuxtseo-shared/brand'
4
+ import { computed } from 'vue'
5
+
6
+ /**
7
+ * A Nuxt SEO module's hand-drawn icon from `nuxtseo-shared/brand`, or the summit
8
+ * mark for the `nuxt-seo` meta module. One even pen in the text colour; only the
9
+ * landing dot takes colour: `free` green when a module shipped it, `pro` violet
10
+ * when Pro read it back. An unknown slug renders nothing.
11
+ *
12
+ * `palette` swaps the theme-aware CSS paints for static colours, for renderers
13
+ * without CSS variables (OG images).
14
+ */
15
+ const { module, landing = 'free', size = 20, palette = 'css' } = defineProps<{
16
+ module: string
17
+ landing?: BrandLanding
18
+ /** Rendered size in px. Smaller icons get a heavier pen and a bigger dot. */
19
+ size?: number
20
+ palette?: 'css' | 'light' | 'dark'
21
+ }>()
22
+
23
+ // The summit lands on its own dot; a list that hides inactive dots hides this one too.
24
+ function summit(landing: BrandLanding) {
25
+ const mark = markDrawing({ size })
26
+ if (landing !== 'none')
27
+ return mark
28
+ return { ...mark, groups: mark.groups.map(group => ({ ...group, shapes: group.shapes.filter(shape => shape._tag !== 'Dot') })) }
29
+ }
30
+
31
+ const svg = computed(() => {
32
+ const drawing = module === 'nuxt-seo'
33
+ ? summit(landing)
34
+ : isBrandIconName(module) ? iconDrawing(module, { landing, size }) : undefined
35
+ if (!drawing)
36
+ return ''
37
+ const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
38
+ return drawingToSvg(drawing, { palette: brandPalette, width: size, height: size })
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
44
+ <span v-if="svg" data-ui="UiModuleIcon" class="inline-flex shrink-0 leading-none" aria-hidden="true" v-html="svg" />
45
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import { BRAND_PALETTE_DARK, BRAND_PALETTE_LIGHT, drawingToSvg, PRO_LETTERING_ASPECT, PRO_LETTERING_SCALE, proLetteringDrawing } from 'nuxtseo-shared/brand'
3
+ import { computed } from 'vue'
4
+
5
+ /**
6
+ * "Pro", lettered with the module icon pen and underlined with one swipe.
7
+ * Set `size` to the wordmark's font size; the lettering scales from it.
8
+ */
9
+ const { size = 18, palette = 'css' } = defineProps<{
10
+ /** Font size of the wordmark it sits beside, in px. */
11
+ size?: number
12
+ /** Static colours for renderers without CSS variables (OG images). */
13
+ palette?: 'css' | 'light' | 'dark'
14
+ }>()
15
+
16
+ const svg = computed(() => {
17
+ const height = Math.round(size * PRO_LETTERING_SCALE)
18
+ const brandPalette = palette === 'css' ? undefined : palette === 'dark' ? BRAND_PALETTE_DARK : BRAND_PALETTE_LIGHT
19
+ return drawingToSvg(proLetteringDrawing(), { palette: brandPalette, width: Math.round(height * PRO_LETTERING_ASPECT), height, attrs: 'aria-hidden="true"' })
20
+ })
21
+ </script>
22
+
23
+ <template>
24
+ <!-- Trusted markup: drawingToSvg only interpolates the brand's own path data. -->
25
+ <span data-ui="UiProLettering" class="inline-flex shrink-0 leading-none" role="img" aria-label="Pro" v-html="svg" />
26
+ </template>
@@ -0,0 +1,311 @@
1
+ <script setup lang="ts">
2
+ import { useElementSize } from '@vueuse/core'
3
+ import { m, useReducedMotion } from 'motion-v'
4
+ import { computed, onMounted, ref, useTemplateRef } from 'vue'
5
+ import { UiAtmosphere } from '#components'
6
+ import { RANGE_DEFAULT_SERIES, RANGE_RIDGE_OPACITY, RANGE_SHADE } from './range-glyphs'
7
+
8
+ // UiRangeTerrain — "the Range". Growth as a mountain range, drawn IN the same
9
+ // shade glyphs instead of an SVG fill — developed from the
10
+ // brand-kit sandbox's "Terrain" experiment (`apps/brand-kit/app/pages/topo-crt.vue`).
11
+ // It is a chart that happens to be beautiful: every ridge is the real series
12
+ // (later ridges are the same values read at a different phase, for depth —
13
+ // never fabricated), never decoration. Pure function of props for the DATA:
14
+ // no random, no Date, so the silhouette is deterministic. The glyph FONT SIZE
15
+ // is the one client-measured exception — a ResizeObserver sizes glyphs to the
16
+ // container's real pixel width (container-query units broke on the mono
17
+ // fallback stack, see the git history), so first paint briefly uses a static
18
+ // CSS fallback size until the client measurement lands.
19
+ //
20
+ // Layering (back → front): UiAtmosphere sun bloom + haze veil sit behind the
21
+ // glyphs; ridges stack front-over-back at opacity 1 / 0.65 / 0.4 (default 3),
22
+ // each built from the same interpolated series read at a different rotation.
23
+
24
+ const {
25
+ series = RANGE_DEFAULT_SERIES,
26
+ ridges = 3,
27
+ columns = 48,
28
+ height = 240,
29
+ sun = 'dusk',
30
+ haze = false,
31
+ animate = true,
32
+ pulse = 0,
33
+ fill = false,
34
+ } = defineProps<{
35
+ /** The trend the range renders. Later ridges are the same values, phase-shifted — real data, never fabricated. */
36
+ series?: number[]
37
+ /** Number of layered ridgelines, front to back. */
38
+ ridges?: number
39
+ /** Grid columns — one sample per column after interpolating `series`. */
40
+ columns?: number
41
+ /** Rendered height in px. */
42
+ height?: number
43
+ /** Sun bloom behind the range. 'none' renders no bloom (for the ink/OG ground). */
44
+ sun?: 'dusk' | 'dawn' | 'none'
45
+ /** Soft mist veil over the whole range, for a hazier read. */
46
+ haze?: boolean
47
+ /** Bottom-up build reveal + slow ridge drift. Reduced motion is always static regardless of this prop. */
48
+ animate?: boolean
49
+ /** Bump this number to fire one shimmer sweep across the range. */
50
+ pulse?: number
51
+ /** Stretch to fill (and bottom-anchor within) the nearest positioned ancestor — for a hero/CTA/OG backdrop use, instead of the default in-flow box. */
52
+ fill?: boolean
53
+ }>()
54
+
55
+ const reduced = useReducedMotion()
56
+ const canAnimate = computed(() => animate && !reduced.value)
57
+
58
+ // Font sizing: measure the real pixel width of the mono glyph stack once
59
+ // (a probe span at a fixed reference size), then derive the exact font-size
60
+ // that makes `columns` characters span the terrain's real measured width.
61
+ // Re-derives on resize via useElementSize — no container-query units.
62
+ const terrainRef = useTemplateRef<HTMLElement>('terrainRef')
63
+ const probeRef = useTemplateRef<HTMLElement>('probeRef')
64
+ const { width: measuredWidth } = useElementSize(terrainRef)
65
+ const GLYPH_PROBE_SIZE = 100
66
+ const glyphWidthRatio = ref<number | null>(null)
67
+
68
+ onMounted(() => {
69
+ const probeWidth = probeRef.value?.getBoundingClientRect().width
70
+ if (probeWidth)
71
+ glyphWidthRatio.value = probeWidth / GLYPH_PROBE_SIZE
72
+ })
73
+
74
+ // Width is the only free dimension: `columns` samples must span the measured
75
+ // box width exactly, monospace. (Row COUNT is then derived from this size
76
+ // below — it is not a second independent input, or the two fought each other:
77
+ // a fixed row-count heuristic forced a row height the width-driven font-size
78
+ // couldn't fit, clipping every row or leaving a bare gap down one side.)
79
+ const fontSizePx = computed(() => {
80
+ if (!measuredWidth.value || !glyphWidthRatio.value)
81
+ return null
82
+ const cols = Math.max(1, Math.round(columns))
83
+ const size = measuredWidth.value / cols / glyphWidthRatio.value
84
+ return Math.max(4, Math.min(48, size))
85
+ })
86
+
87
+ function lerp(a: number, b: number, t: number): number {
88
+ return a + (b - a) * t
89
+ }
90
+
91
+ // Resample `series` (any length) onto exactly `columns` samples — one sample
92
+ // per column, per the contract. Plain linear interpolation, deterministic.
93
+ function interpolateSeries(values: number[], targetLength: number): number[] {
94
+ const n = values.length
95
+ if (n === 0)
96
+ return Array.from<number>({ length: targetLength }).fill(0)
97
+ if (targetLength <= 1)
98
+ return [values[n - 1] ?? 0]
99
+ return Array.from({ length: targetLength }, (_, i) => {
100
+ const t = (i / (targetLength - 1)) * (n - 1)
101
+ const lo = Math.floor(t)
102
+ const hi = Math.min(n - 1, lo + 1)
103
+ const frac = t - lo
104
+ const a = values[lo] ?? 0
105
+ const b = values[hi] ?? a
106
+ return a + (b - a) * frac
107
+ })
108
+ }
109
+
110
+ // Circular rotation — a ridge "behind" the front one reads the same real
111
+ // values at a different phase, which is what makes the layered silhouette
112
+ // read as depth without inventing a single number.
113
+ function rotate(values: number[], offset: number): number[] {
114
+ const n = values.length
115
+ if (n === 0)
116
+ return values
117
+ return Array.from({ length: n }, (_, i) => values[(((i + offset) % n) + n) % n] ?? 0)
118
+ }
119
+
120
+ // Character rows for the grid — an independent resolution choice, bounded so
121
+ // a tiny/huge `height` still renders sensibly. The CSS grid track height
122
+ // (below) is always exactly `height / rows`, whatever that number is — even
123
+ // where a row's own glyph (sized off `columns`, above) renders taller than
124
+ // its track, `overflow: hidden` crops each row to its own slim band, which
125
+ // for a solid block glyph just reads as a finer-grained texture. Decoupling
126
+ // rows from the glyph's font-size (instead of trying to make one row's track
127
+ // equal the glyph's natural line height) is what gives a wide, short box
128
+ // enough vertical resolution to read as a ridgeline instead of a stair-step.
129
+ const totalRows = computed(() => Math.max(14, Math.min(48, Math.round(height / 8))))
130
+
131
+ interface RidgeLayer {
132
+ rows: string[]
133
+ opacity: number
134
+ z: number
135
+ driftPx: number
136
+ driftDuration: number
137
+ }
138
+
139
+ const ridgeLayers = computed<RidgeLayer[]>(() => {
140
+ const count = Math.max(1, Math.round(ridges))
141
+ const interpolated = interpolateSeries(series, Math.max(1, Math.round(columns)))
142
+ const max = Math.max(...series, 1)
143
+ const n = interpolated.length
144
+ const rows = totalRows.value
145
+
146
+ return Array.from({ length: count }, (_, k) => {
147
+ // k: 0 = furthest back, count-1 = front (the undistorted series).
148
+ const t = count > 1 ? k / (count - 1) : 1
149
+ const offset = Math.round((count - 1 - k) * n / (count * 3))
150
+ const values = rotate(interpolated, offset)
151
+
152
+ // Back ridges sit high with little relief (compressed silhouette); the
153
+ // front ridge has a low baseline and the full amplitude (dramatic peaks).
154
+ // Baseline+amplitude never reach 1: sky stays visible above every ridge,
155
+ // even at its own tallest column.
156
+ const baselineFrac = lerp(0.3, 0.08, t)
157
+ const ampFrac = lerp(0.2, 0.55, t)
158
+
159
+ const heights = values.map((v) => {
160
+ const norm = max > 0 ? v / max : 0
161
+ const frac = Math.min(1, baselineFrac + ampFrac * norm)
162
+ return Math.round(frac * rows)
163
+ })
164
+
165
+ // Build rows top-to-bottom; each filled cell shades denser toward the
166
+ // baseline and lighter toward that column's own peak tip.
167
+ const gridRows = Array.from({ length: rows }, (_, r) => {
168
+ const fromBottom = rows - 1 - r
169
+ return heights
170
+ .map((h) => {
171
+ if (fromBottom >= h)
172
+ return ' '
173
+ const depth = h > 0 ? fromBottom / h : 0
174
+ const shadeIdx = Math.max(0, RANGE_SHADE.length - 1 - Math.floor(depth * RANGE_SHADE.length))
175
+ return RANGE_SHADE[shadeIdx]
176
+ })
177
+ .join('')
178
+ })
179
+
180
+ const frontRank = count - 1 - k
181
+ return {
182
+ rows: gridRows,
183
+ opacity: RANGE_RIDGE_OPACITY[Math.min(RANGE_RIDGE_OPACITY.length - 1, frontRank)] ?? 0.15,
184
+ z: k + 1,
185
+ driftPx: k % 2 === 0 ? 4 + frontRank : -(4 + frontRank),
186
+ driftDuration: lerp(19, 12, t),
187
+ }
188
+ })
189
+ })
190
+
191
+ const terrainStyle = computed(() => ({
192
+ 'height': `${height}px`,
193
+ '--terrain-rows': String(totalRows.value),
194
+ ...(fontSizePx.value ? { fontSize: `${fontSizePx.value}px` } : {}),
195
+ }))
196
+ </script>
197
+
198
+ <template>
199
+ <div class="range-terrain-wrap" :class="{ 'range-terrain-wrap--fill': fill }">
200
+ <span v-if="$slots.label" class="sr-only"><slot name="label" /></span>
201
+ <div ref="terrainRef" class="range-terrain" aria-hidden="true" :style="terrainStyle">
202
+ <span ref="probeRef" class="terrain-probe" aria-hidden="true">0</span>
203
+ <UiAtmosphere v-if="sun !== 'none'" :palette="sun" geometry="bloom" intensity="subtle" />
204
+ <UiAtmosphere v-if="haze" palette="mist" geometry="veil" intensity="subtle" />
205
+
206
+ <m.div
207
+ v-for="ridge in ridgeLayers"
208
+ :key="ridge.z"
209
+ class="terrain-ridge"
210
+ :style="{ opacity: ridge.opacity, zIndex: ridge.z }"
211
+ :animate="canAnimate ? { x: [0, ridge.driftPx, 0] } : {}"
212
+ :transition="canAnimate ? { repeat: Infinity, duration: ridge.driftDuration, ease: 'easeInOut' } : { duration: 0 }"
213
+ >
214
+ <m.div
215
+ v-for="(row, r) in ridge.rows"
216
+ :key="r"
217
+ class="terrain-row"
218
+ :initial="canAnimate ? { opacity: 0 } : false"
219
+ :while-in-view="{ opacity: 1 }"
220
+ :in-view-options="{ once: true }"
221
+ :transition="canAnimate ? { delay: (ridge.rows.length - 1 - r) * 0.018, duration: 0.25 } : { duration: 0 }"
222
+ >
223
+ {{ row }}
224
+ </m.div>
225
+ </m.div>
226
+
227
+ <m.div
228
+ v-if="pulse > 0"
229
+ :key="`shimmer-${pulse}`"
230
+ class="terrain-shimmer"
231
+ :initial="{ x: '-40%', opacity: 1 }"
232
+ :animate="{ x: '140%' }"
233
+ :transition="{ duration: 1.1, ease: 'easeInOut' }"
234
+ />
235
+ </div>
236
+ </div>
237
+ </template>
238
+
239
+ <style scoped>
240
+ .range-terrain-wrap {
241
+ position: relative;
242
+ }
243
+
244
+ /* `fill` prop — stretch to the nearest positioned ancestor, bottom-anchoring
245
+ the (fixed-height) terrain box inside it. A modifier class defined here,
246
+ not fought over with a consumer's own scoped override: two same-specificity
247
+ selectors from different SFCs race on load order, and the LATER-loaded
248
+ stylesheet wins regardless of which file the consumer expects to win. */
249
+ .range-terrain-wrap--fill {
250
+ position: absolute;
251
+ inset: 0;
252
+ display: flex;
253
+ flex-direction: column;
254
+ justify-content: flex-end;
255
+ }
256
+
257
+ .range-terrain {
258
+ position: relative;
259
+ overflow: hidden;
260
+ isolation: isolate;
261
+ font-size: 0.85rem; /* static fallback until the client measurement lands */
262
+ }
263
+
264
+ .terrain-probe {
265
+ position: absolute;
266
+ visibility: hidden;
267
+ pointer-events: none;
268
+ top: 0;
269
+ left: 0;
270
+ font-family: var(--font-mono);
271
+ font-size: 100px;
272
+ line-height: 1;
273
+ white-space: pre;
274
+ }
275
+
276
+ .terrain-ridge {
277
+ position: absolute;
278
+ inset: 0;
279
+ display: grid;
280
+ /* Explicit even row tracks — a flex column with percentage flex-basis
281
+ resolves against an INDEFINITE parent height as content-sized (each row
282
+ falls back to its own font-metric height), so the ridge's natural height
283
+ silently overflowed the box by 2x+ and got clipped to only its densest
284
+ baseline rows, reading as one solid fill with no sky. Grid tracks divide
285
+ the box's real height exactly, regardless of font metrics. */
286
+ grid-template-rows: repeat(var(--terrain-rows), 1fr);
287
+ pointer-events: none;
288
+ }
289
+
290
+ .terrain-row {
291
+ display: flex;
292
+ align-items: center;
293
+ overflow: hidden;
294
+ font-family: var(--font-mono);
295
+ font-variant-ligatures: none;
296
+ font-size: 1em;
297
+ line-height: 1;
298
+ white-space: pre;
299
+ color: var(--ui-color-primary-400);
300
+ }
301
+
302
+ .terrain-shimmer {
303
+ position: absolute;
304
+ inset: 0;
305
+ z-index: 20;
306
+ width: 30%;
307
+ pointer-events: none;
308
+ background: linear-gradient(105deg, transparent, color-mix(in oklch, white 45%, transparent), transparent);
309
+ mix-blend-mode: overlay;
310
+ }
311
+ </style>
@@ -0,0 +1,18 @@
1
+ // Shared glyph vocabulary + defaults for "the Range" brand system
2
+ // (UiRangeTerrain). Kept in a plain module (not the `<script
3
+ // setup>` block) so these can be referenced as prop defaults without hitting
4
+ // the temporal-dead-zone ordering `defineProps` compiles to when it must lead
5
+ // the script block.
6
+
7
+ // Four mono shade characters, lightest to densest — the vocabulary the
8
+ // brand-kit sandbox established (`apps/brand-kit/app/pages/topo-crt.vue`,
9
+ // "ASCII mark" + "Terrain" sections).
10
+ export const RANGE_SHADE = ['░', '▒', '▓', '█'] as const
11
+
12
+ // The 18-point click trend from the sandbox's Terrain experiment — the
13
+ // default series UiRangeTerrain renders when a host doesn't pass its own.
14
+ export const RANGE_DEFAULT_SERIES = [12, 18, 15, 22, 28, 24, 33, 30, 38, 44, 40, 52, 48, 61, 58, 73, 69, 88]
15
+
16
+ // Front-to-back ridge opacity ladder — exact for the default 3 ridges,
17
+ // extends gracefully for a caller that asks for more.
18
+ export const RANGE_RIDGE_OPACITY = [1, 0.65, 0.4, 0.28, 0.18]
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ // Browser-like presentation shell shared by product and tools surfaces.
3
+ interface Tab {
4
+ id: string
5
+ label: string
6
+ }
7
+
8
+ const props = defineProps<{
9
+ filename?: string
10
+ tabs?: Tab[]
11
+ }>()
12
+
13
+ const activeTab = defineModel<string>('activeTab', { default: '' })
14
+
15
+ // Initialize activeTab to first tab if not set
16
+ if (!activeTab.value && props.tabs?.[0]) {
17
+ activeTab.value = props.tabs[0].id
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <div>
23
+ <div class="relative">
24
+ <div class="absolute -inset-8 -z-10 pointer-events-none">
25
+ <div class="blob blob-1" />
26
+ <div class="blob blob-2" />
27
+ <div class="blob blob-3" />
28
+ <div class="blob blob-4" />
29
+ </div>
30
+ <div class="bg-default border border-default rounded-2xl overflow-hidden">
31
+ <div class="bg-elevated px-4 py-3 border-b border-default flex items-center gap-2">
32
+ <div class="flex gap-1.5">
33
+ <div class="size-3 rounded-full bg-red-400/60" />
34
+ <div class="size-3 rounded-full bg-yellow-400/60" />
35
+ <div class="size-3 rounded-full bg-green-400/60" />
36
+ </div>
37
+ <template v-if="tabs?.length">
38
+ <div class="flex ml-2">
39
+ <button
40
+ v-for="tab in tabs"
41
+ :key="tab.id"
42
+ type="button"
43
+ class="px-3 py-1 text-xs font-mono rounded-t border-x border-t transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-border-accented)]"
44
+ :class="activeTab === tab.id
45
+ ? 'bg-default border-default text-default'
46
+ : 'bg-transparent border-transparent text-dimmed hover:text-muted'"
47
+ @click="activeTab = tab.id"
48
+ >
49
+ {{ tab.label }}
50
+ </button>
51
+ </div>
52
+ </template>
53
+ <span v-else-if="filename" class="text-xs text-dimmed font-mono ml-2">{{ filename }}</span>
54
+ </div>
55
+ <div class="px-6 py-4 font-mono text-sm text-muted space-y-2">
56
+ <template v-if="tabs?.length">
57
+ <template v-for="tab in tabs" :key="tab.id">
58
+ <div v-show="activeTab === tab.id">
59
+ <slot :name="tab.id" />
60
+ </div>
61
+ </template>
62
+ </template>
63
+ <slot v-else />
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </template>
69
+
70
+ <style scoped>
71
+ .blob {
72
+ position: absolute;
73
+ border-radius: 9999px;
74
+ filter: blur(48px);
75
+ opacity: 0.2;
76
+ }
77
+
78
+ .blob-1 {
79
+ width: 300px;
80
+ height: 300px;
81
+ left: -50px;
82
+ top: -50px;
83
+ background-color: var(--ui-color-info-500);
84
+ animation: blob1 8s ease-in-out infinite;
85
+ }
86
+
87
+ .blob-2 {
88
+ width: 350px;
89
+ height: 350px;
90
+ right: -80px;
91
+ bottom: -80px;
92
+ background-color: var(--ui-color-primary-500);
93
+ animation: blob2 10s ease-in-out infinite;
94
+ }
95
+
96
+ .blob-3 {
97
+ width: 250px;
98
+ height: 250px;
99
+ left: 30%;
100
+ top: 20%;
101
+ background-color: var(--ui-color-info-400);
102
+ animation: blob3 12s ease-in-out infinite;
103
+ }
104
+
105
+ .blob-4 {
106
+ width: 200px;
107
+ height: 200px;
108
+ right: 20%;
109
+ top: -30px;
110
+ background-color: var(--ui-color-primary-400);
111
+ animation: blob4 9s ease-in-out infinite;
112
+ }
113
+
114
+ /* The blobs loop forever beside content (WCAG 2.2.2, 2.3.3): reduced motion
115
+ holds them still at their first frame. */
116
+ @media (prefers-reduced-motion: reduce) {
117
+ .blob-1,
118
+ .blob-2,
119
+ .blob-3,
120
+ .blob-4 {
121
+ animation: none;
122
+ }
123
+ }
124
+
125
+ @keyframes blob1 {
126
+ 0%, 100% { transform: translate(0, 0) scale(1); }
127
+ 50% { transform: translate(60px, 40px) scale(1.3); }
128
+ }
129
+
130
+ @keyframes blob2 {
131
+ 0%, 100% { transform: translate(0, 0) scale(1.2); }
132
+ 50% { transform: translate(-50px, -60px) scale(0.9); }
133
+ }
134
+
135
+ @keyframes blob3 {
136
+ 0%, 100% { transform: translate(0, 0) scale(0.9); }
137
+ 33% { transform: translate(40px, -30px) scale(1.2); }
138
+ 66% { transform: translate(-40px, 30px) scale(0.9); }
139
+ }
140
+
141
+ @keyframes blob4 {
142
+ 0%, 100% { transform: translate(0, 0) scale(1); }
143
+ 50% { transform: translate(-40px, 50px) scale(0.8); }
144
+ }
145
+ </style>