@nous-research/ui 0.15.0 → 0.16.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 (216) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +24 -4
  3. package/dist/fonts.js +1 -0
  4. package/dist/hooks/use-capped-frame.js +1 -0
  5. package/dist/hooks/use-css-var-dims.js +1 -0
  6. package/dist/hooks/use-gpu-tier.js +1 -0
  7. package/dist/hooks/use-render-loop.js +1 -0
  8. package/dist/hooks/use-smooth-controls.js +1 -0
  9. package/dist/index.js +1 -0
  10. package/dist/ui/basic-page.js +1 -0
  11. package/dist/ui/components/animated-count.js +1 -0
  12. package/dist/ui/components/ascii.js +1 -0
  13. package/dist/ui/components/badge.js +2 -1
  14. package/dist/ui/components/badges/nous-girl.js +1 -0
  15. package/dist/ui/components/blend-mode.js +1 -0
  16. package/dist/ui/components/blink.js +1 -0
  17. package/dist/ui/components/button.js +2 -1
  18. package/dist/ui/components/checkbox.js +1 -0
  19. package/dist/ui/components/command-block.js +4 -3
  20. package/dist/ui/components/cursor.js +1 -0
  21. package/dist/ui/components/dropdown-menu.js +1 -0
  22. package/dist/ui/components/fit-text/index.js +1 -0
  23. package/dist/ui/components/graphs/bar-chart.js +1 -0
  24. package/dist/ui/components/graphs/index.js +1 -0
  25. package/dist/ui/components/graphs/line-chart.js +1 -0
  26. package/dist/ui/components/graphs/utils.js +1 -0
  27. package/dist/ui/components/grid/index.js +1 -0
  28. package/dist/ui/components/hover-bg.js +1 -0
  29. package/dist/ui/components/icons/arrow.js +1 -0
  30. package/dist/ui/components/icons/check.js +1 -0
  31. package/dist/ui/components/icons/chevron.js +1 -0
  32. package/dist/ui/components/icons/discord.js +1 -0
  33. package/dist/ui/components/icons/eye.js +1 -0
  34. package/dist/ui/components/icons/gear.js +1 -0
  35. package/dist/ui/components/icons/github.js +1 -0
  36. package/dist/ui/components/icons/hamburger.js +1 -0
  37. package/dist/ui/components/icons/heart.js +1 -0
  38. package/dist/ui/components/icons/index.js +1 -0
  39. package/dist/ui/components/icons/link.js +1 -0
  40. package/dist/ui/components/icons/minus.js +1 -0
  41. package/dist/ui/components/icons/search.js +1 -0
  42. package/dist/ui/components/image-distortion.js +1 -0
  43. package/dist/ui/components/leva-client.js +1 -0
  44. package/dist/ui/components/list-item.js +3 -2
  45. package/dist/ui/components/modal/index.js +1 -0
  46. package/dist/ui/components/modal/modal.css +1 -1
  47. package/dist/ui/components/overlays/blend-modes.js +1 -0
  48. package/dist/ui/components/overlays/glitch.js +1 -0
  49. package/dist/ui/components/overlays/greys.js +1 -0
  50. package/dist/ui/components/overlays/index.js +1 -0
  51. package/dist/ui/components/overlays/lens-layers.js +1 -0
  52. package/dist/ui/components/overlays/lens.js +1 -0
  53. package/dist/ui/components/overlays/noise.js +1 -0
  54. package/dist/ui/components/overlays/vignette.js +1 -0
  55. package/dist/ui/components/poster.js +1 -0
  56. package/dist/ui/components/progress.js +1 -0
  57. package/dist/ui/components/scene-canvas.js +1 -0
  58. package/dist/ui/components/scramble.js +1 -0
  59. package/dist/ui/components/segmented.js +5 -4
  60. package/dist/ui/components/select.js +1 -0
  61. package/dist/ui/components/selection-switcher.js +1 -0
  62. package/dist/ui/components/shader.js +1 -0
  63. package/dist/ui/components/socials.js +1 -0
  64. package/dist/ui/components/spinner.js +1 -0
  65. package/dist/ui/components/stats.js +2 -1
  66. package/dist/ui/components/switch.js +1 -0
  67. package/dist/ui/components/tabs.js +4 -3
  68. package/dist/ui/components/terminal-demo.js +2 -1
  69. package/dist/ui/components/theme-toggle.js +1 -0
  70. package/dist/ui/components/tier-card.js +2 -1
  71. package/dist/ui/components/tv.js +1 -0
  72. package/dist/ui/components/typography/h1.js +1 -0
  73. package/dist/ui/components/typography/h2.js +1 -0
  74. package/dist/ui/components/typography/index.js +1 -0
  75. package/dist/ui/components/typography/legend.js +1 -0
  76. package/dist/ui/components/typography/small.js +1 -0
  77. package/dist/ui/components/watchlist.js +2 -1
  78. package/dist/ui/footer.js +1 -0
  79. package/dist/ui/globals.css +33 -1
  80. package/dist/ui/header.js +1 -0
  81. package/dist/ui/layout-wrapper.js +2 -1
  82. package/dist/utils/color.js +1 -0
  83. package/dist/utils/index.js +1 -0
  84. package/dist/utils/poly.js +1 -0
  85. package/package.json +4 -2
  86. package/src/assets/filler-bg0.webp +0 -0
  87. package/src/assets.d.ts +38 -0
  88. package/src/fonts/Collapse-Bold.woff2 +0 -0
  89. package/src/fonts/Collapse-BoldItalic.woff2 +0 -0
  90. package/src/fonts/Collapse-Italic.woff2 +0 -0
  91. package/src/fonts/Collapse-Light.woff2 +0 -0
  92. package/src/fonts/Collapse-LightItalic.woff2 +0 -0
  93. package/src/fonts/Collapse-Regular.woff2 +0 -0
  94. package/src/fonts/Collapse-Thin.woff2 +0 -0
  95. package/src/fonts/Collapse-ThinItalic.woff2 +0 -0
  96. package/src/fonts/Mondwest-Regular.woff2 +0 -0
  97. package/src/fonts/Neuebit-Bold.woff2 +0 -0
  98. package/src/fonts/RulesCompressed-Medium.woff2 +0 -0
  99. package/src/fonts/RulesCompressed-Regular.woff2 +0 -0
  100. package/src/fonts/RulesExpanded-Bold.woff2 +0 -0
  101. package/src/fonts/RulesExpanded-Regular.woff2 +0 -0
  102. package/src/fonts.ts +6 -0
  103. package/src/hooks/use-capped-frame.ts +18 -0
  104. package/src/hooks/use-css-var-dims.ts +39 -0
  105. package/src/hooks/use-gpu-tier.ts +165 -0
  106. package/src/hooks/use-render-loop.ts +121 -0
  107. package/src/hooks/use-smooth-controls.ts +318 -0
  108. package/src/index.ts +109 -0
  109. package/src/ui/basic-page.tsx +34 -0
  110. package/src/ui/build.css +4 -0
  111. package/src/ui/components/animated-count.stories.tsx +67 -0
  112. package/src/ui/components/animated-count.tsx +168 -0
  113. package/src/ui/components/ascii.stories.tsx +30 -0
  114. package/src/ui/components/ascii.tsx +110 -0
  115. package/src/ui/components/badge.stories.tsx +31 -0
  116. package/src/ui/components/badge.tsx +60 -0
  117. package/src/ui/components/badges/nous-girl.tsx +52 -0
  118. package/src/ui/components/blend-mode.stories.tsx +33 -0
  119. package/src/ui/components/blend-mode.tsx +129 -0
  120. package/src/ui/components/blink.stories.tsx +32 -0
  121. package/src/ui/components/blink.tsx +21 -0
  122. package/src/ui/components/button.stories.tsx +68 -0
  123. package/src/ui/components/button.tsx +170 -0
  124. package/src/ui/components/checkbox.stories.tsx +113 -0
  125. package/src/ui/components/checkbox.tsx +36 -0
  126. package/src/ui/components/command-block.stories.tsx +52 -0
  127. package/src/ui/components/command-block.tsx +86 -0
  128. package/src/ui/components/cursor.tsx +115 -0
  129. package/src/ui/components/dropdown-menu.stories.tsx +52 -0
  130. package/src/ui/components/dropdown-menu.tsx +117 -0
  131. package/src/ui/components/fit-text/fit-text.css +42 -0
  132. package/src/ui/components/fit-text/index.stories.tsx +33 -0
  133. package/src/ui/components/fit-text/index.tsx +45 -0
  134. package/src/ui/components/graphs/bar-chart.tsx +153 -0
  135. package/src/ui/components/graphs/index.stories.tsx +64 -0
  136. package/src/ui/components/graphs/index.tsx +4 -0
  137. package/src/ui/components/graphs/line-chart.tsx +213 -0
  138. package/src/ui/components/graphs/utils.tsx +265 -0
  139. package/src/ui/components/grid/grid.css +79 -0
  140. package/src/ui/components/grid/index.tsx +19 -0
  141. package/src/ui/components/hover-bg.stories.tsx +29 -0
  142. package/src/ui/components/hover-bg.tsx +15 -0
  143. package/src/ui/components/icons/arrow.tsx +42 -0
  144. package/src/ui/components/icons/check.tsx +14 -0
  145. package/src/ui/components/icons/chevron.tsx +45 -0
  146. package/src/ui/components/icons/discord.tsx +16 -0
  147. package/src/ui/components/icons/eye.tsx +12 -0
  148. package/src/ui/components/icons/gear.tsx +51 -0
  149. package/src/ui/components/icons/github.tsx +16 -0
  150. package/src/ui/components/icons/hamburger.tsx +52 -0
  151. package/src/ui/components/icons/heart.tsx +12 -0
  152. package/src/ui/components/icons/index.ts +12 -0
  153. package/src/ui/components/icons/link.tsx +14 -0
  154. package/src/ui/components/icons/minus.tsx +14 -0
  155. package/src/ui/components/icons/search.tsx +28 -0
  156. package/src/ui/components/image-distortion.stories.tsx +120 -0
  157. package/src/ui/components/image-distortion.tsx +498 -0
  158. package/src/ui/components/leva-client.tsx +14 -0
  159. package/src/ui/components/list-item.stories.tsx +83 -0
  160. package/src/ui/components/list-item.tsx +37 -0
  161. package/src/ui/components/modal/index.stories.tsx +46 -0
  162. package/src/ui/components/modal/index.tsx +48 -0
  163. package/src/ui/components/modal/modal.css +36 -0
  164. package/src/ui/components/overlays/blend-modes.ts +13 -0
  165. package/src/ui/components/overlays/glitch.tsx +243 -0
  166. package/src/ui/components/overlays/greys.tsx +386 -0
  167. package/src/ui/components/overlays/index.tsx +47 -0
  168. package/src/ui/components/overlays/lens-layers.tsx +119 -0
  169. package/src/ui/components/overlays/lens.ts +91 -0
  170. package/src/ui/components/overlays/noise.tsx +174 -0
  171. package/src/ui/components/overlays/vignette.tsx +60 -0
  172. package/src/ui/components/poster.stories.tsx +513 -0
  173. package/src/ui/components/poster.tsx +411 -0
  174. package/src/ui/components/progress.stories.tsx +48 -0
  175. package/src/ui/components/progress.tsx +56 -0
  176. package/src/ui/components/scene-canvas.tsx +254 -0
  177. package/src/ui/components/scramble.stories.tsx +49 -0
  178. package/src/ui/components/scramble.tsx +95 -0
  179. package/src/ui/components/segmented.stories.tsx +101 -0
  180. package/src/ui/components/segmented.tsx +81 -0
  181. package/src/ui/components/select.stories.tsx +88 -0
  182. package/src/ui/components/select.tsx +267 -0
  183. package/src/ui/components/selection-switcher.tsx +44 -0
  184. package/src/ui/components/shader.tsx +83 -0
  185. package/src/ui/components/socials.tsx +42 -0
  186. package/src/ui/components/spinner.stories.tsx +101 -0
  187. package/src/ui/components/spinner.tsx +60 -0
  188. package/src/ui/components/stats.stories.tsx +24 -0
  189. package/src/ui/components/stats.tsx +53 -0
  190. package/src/ui/components/switch.stories.tsx +77 -0
  191. package/src/ui/components/switch.tsx +48 -0
  192. package/src/ui/components/tabs.stories.tsx +101 -0
  193. package/src/ui/components/tabs.tsx +66 -0
  194. package/src/ui/components/terminal-demo.stories.tsx +67 -0
  195. package/src/ui/components/terminal-demo.tsx +189 -0
  196. package/src/ui/components/theme-toggle.stories.tsx +47 -0
  197. package/src/ui/components/theme-toggle.tsx +66 -0
  198. package/src/ui/components/tier-card.stories.tsx +217 -0
  199. package/src/ui/components/tier-card.tsx +190 -0
  200. package/src/ui/components/tv.stories.tsx +37 -0
  201. package/src/ui/components/tv.tsx +257 -0
  202. package/src/ui/components/typography/h1.tsx +18 -0
  203. package/src/ui/components/typography/h2.tsx +18 -0
  204. package/src/ui/components/typography/index.tsx +54 -0
  205. package/src/ui/components/typography/legend.tsx +24 -0
  206. package/src/ui/components/typography/small.tsx +11 -0
  207. package/src/ui/components/watchlist.stories.tsx +33 -0
  208. package/src/ui/components/watchlist.tsx +105 -0
  209. package/src/ui/fonts.css +63 -0
  210. package/src/ui/footer.tsx +111 -0
  211. package/src/ui/globals.css +383 -0
  212. package/src/ui/header.tsx +398 -0
  213. package/src/ui/layout-wrapper.tsx +11 -0
  214. package/src/utils/color.ts +21 -0
  215. package/src/utils/index.ts +62 -0
  216. package/src/utils/poly.ts +26 -0
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useState } from 'react'
4
+
5
+ import { cn } from '../../utils'
6
+
7
+ import { Small } from './typography/small'
8
+
9
+ /**
10
+ * A "copy to clipboard" button that briefly shows a "Copied!" confirmation.
11
+ * Designed to sit alongside a short command string, not as a general button.
12
+ */
13
+ export function CopyButton({
14
+ children,
15
+ className,
16
+ copiedLabel = 'Copied!',
17
+ label = 'Copy',
18
+ resetDelayMs = 2000,
19
+ text
20
+ }: CopyButtonProps) {
21
+ const [copied, setCopied] = useState(false)
22
+
23
+ const handleCopy = useCallback(() => {
24
+ void navigator.clipboard.writeText(text).then(() => {
25
+ setCopied(true)
26
+ setTimeout(() => setCopied(false), resetDelayMs)
27
+ })
28
+ }, [resetDelayMs, text])
29
+
30
+ return (
31
+ <button
32
+ className={cn(
33
+ 'font-courier text-display cursor-pointer border-none bg-transparent text-xs',
34
+ 'tracking-widest',
35
+ 'hover:text-midground tap-highlight-transparent transition-colors',
36
+ 'flex items-center justify-center',
37
+ copied ? 'text-midground' : 'text-text-secondary',
38
+ className
39
+ )}
40
+ onClick={handleCopy}
41
+ type="button"
42
+ >
43
+ {children ?? (copied ? copiedLabel : label)}
44
+ </button>
45
+ )
46
+ }
47
+
48
+ /**
49
+ * A labeled, copy-able command (or code) display. Pairs `<CopyButton>` with
50
+ * a monospace code block. Used for install/setup instructions.
51
+ */
52
+ export function CommandBlock({ className, code, label }: CommandBlockProps) {
53
+ return (
54
+ <div className={cn('flex flex-col gap-1', className)}>
55
+ <div className="flex items-center justify-between">
56
+ <Small className="opacity-50">{label}</Small>
57
+
58
+ <CopyButton text={code} />
59
+ </div>
60
+
61
+ <div
62
+ className={cn(
63
+ 'bg-background/40 font-courier border border-current/20',
64
+ 'px-3 py-2 text-[0.6875rem] leading-relaxed lowercase'
65
+ )}
66
+ >
67
+ <code className="break-all">{code}</code>
68
+ </div>
69
+ </div>
70
+ )
71
+ }
72
+
73
+ interface CommandBlockProps {
74
+ className?: string
75
+ code: string
76
+ label: string
77
+ }
78
+
79
+ interface CopyButtonProps {
80
+ children?: React.ReactNode
81
+ className?: string
82
+ copiedLabel?: string
83
+ label?: string
84
+ resetDelayMs?: number
85
+ text: string
86
+ }
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ const INTERACTIVE =
6
+ 'a, button, [role="button"], input, textarea, select, [data-cursor]'
7
+
8
+ const HAND =
9
+ 'M6.84 21.83c-.47-.6-1.05-1.82-2.07-3.34-.58-.83-2.01-2.41-2.45-3.23a2.1 2.1 0 0 1-.25-1.67 2.2 2.2 0 0 1 2.39-1.67c.85.18 1.63.6 2.25 1.2.43.41.82.85 1.18 1.32.27.34.33.47.63.85.3.39.5.77.35.2-.11-.83-.31-2.23-.6-3.48-.21-.95-.26-1.1-.46-1.82s-.32-1.32-.54-2.13c-.2-.8-.35-1.62-.46-2.44a4.7 4.7 0 0 1 .43-3.08c.58-.55 1.44-.7 2.17-.37a4.4 4.4 0 0 1 1.57 2.17c.43 1.07.72 2.19.86 3.33.27 1.67.79 4.1.8 4.6 0-.61-.11-1.91 0-2.5.12-.6.54-1.1 1.12-1.33.5-.15 1.02-.19 1.53-.1.52.1.98.4 1.29.83.38.98.6 2 .63 3.05.04-.91.2-1.82.47-2.7.28-.39.68-.67 1.15-.8.55-.1 1.11-.1 1.66 0 .46.15.85.44 1.14.82.35.88.56 1.82.63 2.77 0 .23.12-.65.48-1.24a1.67 1.67 0 1 1 3.17 1.07v3.77c-.06.97-.2 1.94-.4 2.9-.29.85-.7 1.65-1.2 2.38-.8.9-1.48 1.92-1.98 3.02a6.67 6.67 0 0 0 .03 3.2c-.68.07-1.37.07-2.05 0-.65-.1-1.45-1.4-1.67-1.8a.63.63 0 0 0-1.13 0c-.37.64-1.18 1.79-1.75 1.85-1.12.14-3.42 0-5.23 0 0 0 .3-1.66-.39-2.27-.68-.6-1.38-1.3-1.9-1.76l-1.4-1.6Z'
10
+
11
+ export function Cursor({ scale = 0.8 }: { scale?: number }) {
12
+ const $root = useRef<HTMLDivElement>(null)
13
+ const $arrow = useRef<HTMLDivElement>(null)
14
+ const $ptr = useRef<HTMLDivElement>(null)
15
+
16
+ useEffect(() => {
17
+ const [root, arrow, ptr] = [$root.current, $arrow.current, $ptr.current]
18
+
19
+ if (!root || !arrow || !ptr) {
20
+ return
21
+ }
22
+
23
+ const on = (
24
+ el: EventTarget,
25
+ ev: string,
26
+ fn: EventListener,
27
+ opts?: AddEventListenerOptions
28
+ ) => {
29
+ el.addEventListener(ev, fn, opts)
30
+
31
+ return () => el.removeEventListener(ev, fn)
32
+ }
33
+
34
+ return [
35
+ on(
36
+ document,
37
+ 'mousemove',
38
+ (e: Event) => {
39
+ const { clientX: x, clientY: y } = e as MouseEvent
40
+ root.style.translate = `${x}px ${y}px`
41
+ root.style.opacity = '1'
42
+ },
43
+ { passive: true }
44
+ ),
45
+
46
+ on(
47
+ document,
48
+ 'mouseover',
49
+ (e: Event) => {
50
+ const isPtr = !!(e.target as HTMLElement).closest?.(INTERACTIVE)
51
+ arrow.style.opacity = isPtr ? '0' : '1'
52
+ ptr.style.opacity = isPtr ? '1' : '0'
53
+ },
54
+ { passive: true }
55
+ ),
56
+
57
+ on(document, 'mousedown', () => {
58
+ root.style.transform = 'translate(1px, 1px)'
59
+ }),
60
+ on(document, 'mouseup', () => {
61
+ root.style.transform = ''
62
+ }),
63
+ on(document.documentElement, 'mouseleave', () => {
64
+ root.style.opacity = '0'
65
+ }),
66
+ on(document.documentElement, 'mouseenter', () => {
67
+ root.style.opacity = '1'
68
+ })
69
+ ].reduce((_, fn) => fn, undefined as unknown as void)
70
+ }, [])
71
+
72
+ return (
73
+ <div
74
+ aria-hidden
75
+ ref={$root}
76
+ style={{
77
+ filter: 'drop-shadow(1px 2px 0 #000)',
78
+ height: 32 * scale,
79
+ left: 0,
80
+ opacity: 0,
81
+ pointerEvents: 'none',
82
+ position: 'fixed',
83
+ top: 0,
84
+ width: 32 * scale,
85
+ willChange: 'translate',
86
+ zIndex: 9999
87
+ }}
88
+ >
89
+ <div ref={$arrow} style={{ inset: 0, position: 'absolute' }}>
90
+ <svg viewBox="0 0 16 16">
91
+ <path
92
+ d="M1 1L1 14L5 10L8 15L10 14L7 9L12 9L1 1Z"
93
+ fill="#fff"
94
+ stroke="#000"
95
+ strokeLinejoin="round"
96
+ strokeWidth={1}
97
+ />
98
+ </svg>
99
+ </div>
100
+
101
+ <div ref={$ptr} style={{ inset: 0, opacity: 0, position: 'absolute' }}>
102
+ <svg viewBox="0 0 28 29">
103
+ <path
104
+ d={HAND}
105
+ fill="#fff"
106
+ stroke="#000"
107
+ strokeLinejoin="round"
108
+ strokeWidth={2}
109
+ style={{ paintOrder: 'stroke fill' }}
110
+ />
111
+ </svg>
112
+ </div>
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,52 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { DropdownMenu } from './dropdown-menu'
5
+ import { Small } from './typography/small'
6
+
7
+ const OPTIONS = [
8
+ { label: 'Option A', value: 'a' as const },
9
+ { label: 'Option B', value: 'b' as const },
10
+ { label: 'Option C', value: 'c' as const }
11
+ ]
12
+
13
+ function Demo({ direction }: { direction: 'down' | 'left' | 'right' | 'up' }) {
14
+ const [value, setValue] = useState<'a' | 'b' | 'c'>('a')
15
+
16
+ return (
17
+ <DropdownMenu
18
+ direction={direction}
19
+ onChange={setValue}
20
+ options={OPTIONS}
21
+ value={value}
22
+ />
23
+ )
24
+ }
25
+
26
+ const meta: Meta<typeof DropdownMenu> = {
27
+ component: DropdownMenu,
28
+ title: 'Components/DropdownMenu'
29
+ }
30
+
31
+ export default meta
32
+
33
+ type Story = StoryObj<typeof DropdownMenu>
34
+
35
+ export const Down: Story = { render: () => <Demo direction="down" /> }
36
+ export const Up: Story = { render: () => <Demo direction="up" /> }
37
+ export const Right: Story = { render: () => <Demo direction="right" /> }
38
+ export const Left: Story = { render: () => <Demo direction="left" /> }
39
+
40
+ export const AllDirections: Story = {
41
+ render: () => (
42
+ <div className="flex gap-10">
43
+ {(['down', 'up', 'right', 'left'] as const).map(direction => (
44
+ <div className="flex flex-col gap-1" key={direction}>
45
+ <Small className="capitalize opacity-40">{direction}</Small>
46
+
47
+ <Demo direction={direction} />
48
+ </div>
49
+ ))}
50
+ </div>
51
+ )
52
+ }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useId, useRef, useState } from 'react'
4
+
5
+ import { cn } from '../../utils'
6
+
7
+ const font = 'font-mondwest text-[.9375rem] leading-[1.4] tracking-[0.1875rem]'
8
+
9
+ type Direction = 'down' | 'up' | 'left' | 'right'
10
+
11
+ type AnchorStyle = React.CSSProperties & Record<string, string | number>
12
+
13
+ export function DropdownMenu<T extends string>({
14
+ className,
15
+ direction = 'down',
16
+ onChange,
17
+ options,
18
+ value
19
+ }: {
20
+ className?: string
21
+ direction?: Direction
22
+ onChange: (value: T) => void
23
+ options: { label: string; value: T }[]
24
+ value: T
25
+ }) {
26
+ const id = useId()
27
+ const [open, setOpen] = useState(false)
28
+ const ref = useRef<HTMLSpanElement>(null)
29
+
30
+ const anchor = `--dropdown-${id.replace(/:/g, '')}`
31
+
32
+ const panelStyle: AnchorStyle = {
33
+ position: 'fixed',
34
+ positionAnchor: anchor,
35
+ positionTryFallbacks:
36
+ direction === 'left' || direction === 'right'
37
+ ? 'flip-inline, flip-block'
38
+ : 'flip-block, flip-inline',
39
+ ...(direction === 'up' && {
40
+ left: 'calc(anchor(left) - 0.5rem)',
41
+ top: 'calc(anchor(top) + 1rem)',
42
+ transform: 'translateY(-100%)'
43
+ }),
44
+ ...(direction === 'right' && {
45
+ left: 'calc(anchor(right))',
46
+ top: 'calc(anchor(top) - 0.5rem)'
47
+ }),
48
+ ...(direction === 'left' && {
49
+ left: 'calc(anchor(left) - 1px)',
50
+ top: 'calc(anchor(top) - 0.5rem)',
51
+ transform: 'translateX(-100%)'
52
+ }),
53
+ ...(direction === 'down' && {
54
+ left: 'calc(anchor(left) - 0.5rem)',
55
+ top: 'calc(anchor(top) - 0.5rem)'
56
+ })
57
+ }
58
+
59
+ useEffect(() => {
60
+ if (!open) {
61
+ return
62
+ }
63
+
64
+ const ac = new AbortController()
65
+ document.addEventListener(
66
+ 'mousedown',
67
+ e => {
68
+ if (!ref.current?.contains(e.target as Node)) {
69
+ setOpen(false)
70
+ }
71
+ },
72
+ { signal: ac.signal }
73
+ )
74
+
75
+ return () => ac.abort()
76
+ }, [open])
77
+
78
+ return (
79
+ <span
80
+ className={cn('relative inline-block align-top', className)}
81
+ ref={ref}
82
+ >
83
+ <span
84
+ className={cn(font, 'inline-block cursor-pointer hover:underline')}
85
+ onClick={() => setOpen(!open)}
86
+ style={{ anchorName: anchor } as AnchorStyle}
87
+ >
88
+ {options.find(o => o.value === value)?.label ?? value}{' '}
89
+ {open ? '↑' : '↓'}
90
+ </span>
91
+
92
+ {open && (
93
+ <div
94
+ className="bg-background-base z-50 flex flex-col"
95
+ style={panelStyle}
96
+ >
97
+ {options.map(o => (
98
+ <span
99
+ className={cn(
100
+ font,
101
+ 'block cursor-pointer p-2 whitespace-nowrap',
102
+ o.value === value ? 'underline' : 'hover:bg-midground/10'
103
+ )}
104
+ key={o.value}
105
+ onClick={() => {
106
+ onChange(o.value)
107
+ setOpen(false)
108
+ }}
109
+ >
110
+ {o.label}
111
+ </span>
112
+ ))}
113
+ </div>
114
+ )}
115
+ </span>
116
+ )
117
+ }
@@ -0,0 +1,42 @@
1
+ .fit-text {
2
+ --fit-captured-length: initial;
3
+ --fit-support-sentinel: var(--fit-captured-length, 9999px);
4
+
5
+ display: flex;
6
+ container-type: inline-size;
7
+
8
+ > [aria-hidden] {
9
+ visibility: hidden;
10
+ }
11
+
12
+ > :not([aria-hidden]) {
13
+ flex-grow: 1;
14
+ container-type: inline-size;
15
+
16
+ --fit-captured-length: 100cqi;
17
+ --fit-available-space: var(--fit-captured-length);
18
+
19
+ > * {
20
+ --fit-support-sentinel: inherit;
21
+ --fit-captured-length: 100cqi;
22
+ --fit-ratio: tan(
23
+ atan2(
24
+ var(--fit-available-space),
25
+ var(--fit-available-space) - var(--fit-captured-length)
26
+ )
27
+ );
28
+
29
+ display: block;
30
+ inline-size: var(--fit-available-space);
31
+ font-size: clamp(
32
+ var(--fit-min, 1em),
33
+ 1em * var(--fit-ratio),
34
+ var(--fit-max, infinity * 1px) - var(--fit-support-sentinel)
35
+ );
36
+
37
+ @container (inline-size > 0) {
38
+ white-space: nowrap;
39
+ }
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,33 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { FitText } from '../fit-text'
4
+
5
+ const meta: Meta<typeof FitText> = {
6
+ args: { children: 'Fit Text', max: 'infinity * 1px', min: '1em' },
7
+ component: FitText,
8
+ title: 'Components/FitText'
9
+ }
10
+
11
+ export default meta
12
+
13
+ type Story = StoryObj<typeof FitText>
14
+
15
+ export const Playground: Story = {}
16
+
17
+ export const Fills: Story = {
18
+ render: () => (
19
+ <div className="w-full">
20
+ <FitText className="font-sans font-bold">Design System</FitText>
21
+ </div>
22
+ )
23
+ }
24
+
25
+ export const CappedMax: Story = {
26
+ render: () => (
27
+ <div className="w-full">
28
+ <FitText className="font-mondwest" max="4rem">
29
+ Capped max at 4rem
30
+ </FitText>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,45 @@
1
+ 'use client'
2
+
3
+ import { createElement } from 'react'
4
+
5
+ import { cn, type PolyProps, polyRef } from '../../../utils'
6
+
7
+ export const FitText = polyRef<'span', OwnProps>(
8
+ (
9
+ { as, children, className, max, min = '1em', style: baseStyle, ...rest },
10
+ ref
11
+ ) => {
12
+ if (typeof children !== 'string') {
13
+ return null
14
+ }
15
+
16
+ const style = {
17
+ '--fit-max': max ?? 'infinity * 1px',
18
+ '--fit-min': min,
19
+ ...baseStyle
20
+ } as React.CSSProperties
21
+
22
+ return createElement(
23
+ (as ?? 'span') as React.ElementType,
24
+ { ...rest, className: cn('fit-text', className), ref, style },
25
+ <>
26
+ <span>
27
+ <span>{children}</span>
28
+ </span>
29
+
30
+ <span aria-hidden="true">{children}</span>
31
+ </>
32
+ )
33
+ }
34
+ )
35
+
36
+ interface OwnProps {
37
+ children: string
38
+ max?: string
39
+ min?: string
40
+ }
41
+
42
+ export type FitTextProps<T extends React.ElementType = 'span'> = PolyProps<
43
+ T,
44
+ OwnProps
45
+ >
@@ -0,0 +1,153 @@
1
+ 'use client'
2
+
3
+ import * as Plot from '@observablehq/plot'
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
5
+
6
+ import { cn } from '../../../utils'
7
+
8
+ import {
9
+ accessor,
10
+ CHART_MARGINS,
11
+ CHART_STYLE,
12
+ type ChartProps,
13
+ Crosshair,
14
+ type CrosshairState,
15
+ type DataPoint,
16
+ setupCrosshair,
17
+ stylePlot,
18
+ useDims,
19
+ withChartBlend
20
+ } from './utils'
21
+
22
+ export const BarChart = withChartBlend(
23
+ <T extends DataPoint>({
24
+ backgroundColor: _,
25
+ className,
26
+ color: fillColor,
27
+ data = [],
28
+ formatTooltip,
29
+ formatX: formatXProp,
30
+ formatY: formatYProp,
31
+ x = 'label' as keyof T,
32
+ xDomain,
33
+ xTicks = [0, 50000, 100000],
34
+ y = 'value' as keyof T,
35
+ yDomain = [0, 10],
36
+ yTicks = [10, 8, 4, 2],
37
+ ...props
38
+ }: BarChartProps<T> & { backgroundColor?: string; color?: string }) => {
39
+ const ref = useRef<HTMLDivElement>(null)
40
+ const plotRef = useRef<HTMLDivElement>(null)
41
+ const [crosshair, setCrosshair] = useState<CrosshairState>({ x: null })
42
+ const dims = useDims(ref)
43
+
44
+ const formatX = useCallback(
45
+ (v: unknown) =>
46
+ formatXProp?.(v) ??
47
+ (typeof v === 'number' ? v.toLocaleString('en-US') : String(v)),
48
+ [formatXProp]
49
+ )
50
+
51
+ const formatY = useCallback(
52
+ (v: number) => formatYProp?.(v) ?? String(v),
53
+ [formatYProp]
54
+ )
55
+
56
+ const getX = useMemo(() => accessor<T, unknown>(x), [x])
57
+ const getY = useMemo(() => accessor<T, number>(y), [y])
58
+
59
+ useEffect(() => {
60
+ if (
61
+ !ref.current ||
62
+ !plotRef.current ||
63
+ !data.length ||
64
+ !dims.h ||
65
+ !dims.w
66
+ ) {
67
+ return
68
+ }
69
+
70
+ plotRef.current.innerHTML = ''
71
+
72
+ const [xMin, xMax] = [
73
+ xDomain?.[0] ?? 0,
74
+ xDomain?.[1] ?? Math.max(...data.map(d => getX(d) as number))
75
+ ]
76
+
77
+ const plot = Plot.plot({
78
+ ...CHART_MARGINS,
79
+ height: dims.h,
80
+ marks: [
81
+ Plot.rectY(data, {
82
+ fill: fillColor ?? 'currentColor',
83
+ fillOpacity: 0.3,
84
+ interval: (xMax - xMin) / data.length,
85
+ x: getX as (d: T) => unknown,
86
+ y: getY
87
+ }),
88
+ Plot.axisX({ tickFormat: formatX, ticks: xTicks })
89
+ ],
90
+ style: CHART_STYLE,
91
+ width: dims.w,
92
+ x: { domain: [xMin, xMax], label: null, type: 'linear' },
93
+ y: {
94
+ domain: yDomain,
95
+ grid: true,
96
+ label: null,
97
+ tickFormat: formatY,
98
+ ticks: yTicks
99
+ }
100
+ })
101
+
102
+ stylePlot(plot as HTMLElement)
103
+ plotRef.current.appendChild(plot)
104
+
105
+ const cleanup = setupCrosshair(
106
+ ref.current,
107
+ data,
108
+ d => getX(d) as number,
109
+ getY,
110
+ yDomain,
111
+ d => formatTooltip?.(d) ?? `${formatX(getX(d))}: ${formatY(getY(d))}`,
112
+ setCrosshair
113
+ )
114
+
115
+ return cleanup
116
+ }, [
117
+ data,
118
+ dims.h,
119
+ dims.w,
120
+ fillColor,
121
+ formatTooltip,
122
+ formatX,
123
+ formatY,
124
+ getX,
125
+ getY,
126
+ xDomain,
127
+ xTicks,
128
+ yDomain,
129
+ yTicks
130
+ ])
131
+
132
+ return (
133
+ <div
134
+ className={cn('relative aspect-4/1 w-full overflow-clip', className)}
135
+ ref={ref}
136
+ {...props}
137
+ >
138
+ <div className="absolute inset-0" ref={plotRef} />
139
+
140
+ <Crosshair
141
+ color={fillColor}
142
+ containerWidth={dims.w}
143
+ height={dims.h}
144
+ {...crosshair}
145
+ />
146
+ </div>
147
+ )
148
+ }
149
+ )
150
+
151
+ interface BarChartProps<T extends DataPoint> extends ChartProps<T> {
152
+ xDomain?: [number, number]
153
+ }
@@ -0,0 +1,64 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { BarChart, LineChart } from '../graphs'
4
+ import { Small } from '../typography/small'
5
+
6
+ const LINE_DATA = (['primary', 'secondary', 'tertiary'] as const).flatMap(
7
+ (series, si) =>
8
+ [0, 50000, 100000, 150000].map((label, i) => ({
9
+ label,
10
+ series,
11
+ value: 0.15 + si * 0.1 + (i % 2) * 0.05 + Math.sin(i + si) * 0.08
12
+ }))
13
+ )
14
+
15
+ const BAR_DATA = (() => {
16
+ let x = 42
17
+ const f = () => (x = (1103515245 * x + 12345) % 0x80000000) / 0x80000000
18
+
19
+ return Array.from({ length: 100 }, (_, i) => ({
20
+ label: (i / 99) * 150000,
21
+ value: f() * 10
22
+ }))
23
+ })()
24
+
25
+ const meta = {
26
+ parameters: { layout: 'padded' },
27
+ title: 'Components/Graphs'
28
+ } satisfies Meta
29
+
30
+ export default meta
31
+
32
+ type Story = StoryObj
33
+
34
+ export const Line: Story = {
35
+ render: () => (
36
+ <div>
37
+ <Small className="mb-5 block opacity-50">LineChart</Small>
38
+
39
+ <LineChart
40
+ data={LINE_DATA}
41
+ series="series"
42
+ x="label"
43
+ y="value"
44
+ yDomain={[0, 0.5]}
45
+ />
46
+ </div>
47
+ )
48
+ }
49
+
50
+ export const Bar: Story = {
51
+ render: () => (
52
+ <div>
53
+ <Small className="mb-5 block opacity-50">BarChart</Small>
54
+
55
+ <BarChart
56
+ data={BAR_DATA}
57
+ x="label"
58
+ xDomain={[0, 150000]}
59
+ y="value"
60
+ yDomain={[0, 10]}
61
+ />
62
+ </div>
63
+ )
64
+ }