@nous-research/ui 0.14.2 → 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,411 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import fillerBg from '../../assets/filler-bg0.webp'
6
+ import { cn } from '../../utils'
7
+
8
+ import { Blink } from './blink'
9
+ import { ImageDistortion } from './image-distortion'
10
+ import { Typography } from './typography'
11
+ import { Small } from './typography/small'
12
+
13
+ import type { AutoPlayPattern } from './image-distortion'
14
+
15
+ const ASPECT_CONFIG: Record<
16
+ PosterAspect,
17
+ { defaultLayout: 'split' | 'stacked'; height: number; width: number }
18
+ > = {
19
+ landscape: { defaultLayout: 'split', height: 1080, width: 1920 },
20
+ portrait: { defaultLayout: 'split', height: 1350, width: 1080 },
21
+ square: { defaultLayout: 'split', height: 1080, width: 1080 },
22
+ story: { defaultLayout: 'stacked', height: 1920, width: 1080 },
23
+ wide: { defaultLayout: 'split', height: 900, width: 1600 }
24
+ }
25
+
26
+ const DEFAULT_SRC =
27
+ (fillerBg as { src?: string }).src ?? (fillerBg as unknown as string)
28
+
29
+ function useUtcClock() {
30
+ const [now, setNow] = useState<Date | null>(null)
31
+
32
+ useEffect(() => {
33
+ setNow(new Date())
34
+ const id = setInterval(() => setNow(new Date()), 1000)
35
+
36
+ return () => clearInterval(id)
37
+ }, [])
38
+
39
+ return now ? now.toISOString().slice(11, 19) : '--:--:--'
40
+ }
41
+
42
+ function CornerMark({ className }: { className?: string }) {
43
+ return (
44
+ <span
45
+ aria-hidden
46
+ className={cn(
47
+ 'pointer-events-none absolute block size-4 opacity-50',
48
+ className
49
+ )}
50
+ >
51
+ <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-current" />
52
+
53
+ <span className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-current" />
54
+ </span>
55
+ )
56
+ }
57
+
58
+ function ChannelDot() {
59
+ return (
60
+ <span className="flex items-center gap-1.5">
61
+ <span className="bg-midground size-1.5 animate-pulse rounded-full" />
62
+
63
+ <Small className="opacity-70">REC</Small>
64
+ </span>
65
+ )
66
+ }
67
+
68
+ function ScanlineOverlay() {
69
+ return (
70
+ <div
71
+ aria-hidden
72
+ className="pointer-events-none absolute inset-0 opacity-20 mix-blend-overlay"
73
+ style={{
74
+ backgroundImage:
75
+ 'repeating-linear-gradient(0deg, transparent 0, transparent 2px, rgba(255,255,255,0.08) 2px, rgba(255,255,255,0.08) 3px)'
76
+ }}
77
+ />
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Social-ready glitchy card built around the haptic-distortion image
83
+ * component. The poster runs the sword-guy distortion on an auto-animated
84
+ * slash pattern so it can be screen-recorded as a GIF without a human
85
+ * moving a cursor.
86
+ *
87
+ * Two variants, matching actual use cases:
88
+ * - `'vibe'` (default): full-bleed distorted image with just registration
89
+ * marks and a tiny "Hermes Agent" mark in the corner — mirrors the
90
+ * overlay on the Hermes agent website.
91
+ * - `'dispatch'`: broadcast-card layout with sidebar copy, numbered tags,
92
+ * and chrome — for when the poster needs to carry information.
93
+ */
94
+ export function Poster({
95
+ aspect = 'square',
96
+ autoPlay = 'slash',
97
+ body,
98
+ border = true,
99
+ channel,
100
+ children,
101
+ className,
102
+ cornerMarks = true,
103
+ eyebrow,
104
+ headline = ['An Agent', 'That Grows', 'With You.'],
105
+ layout,
106
+ scale = 1,
107
+ seal = 'MIT · 2026',
108
+ signature,
109
+ src = DEFAULT_SRC,
110
+ tags,
111
+ tint,
112
+ tintStrength,
113
+ variant = 'vibe',
114
+ ...rest
115
+ }: PosterProps) {
116
+ const config = ASPECT_CONFIG[aspect]
117
+ const resolvedLayout = layout ?? config.defaultLayout
118
+
119
+ // Use aspect-ratio + max-width/height so the poster fluidly fits any parent
120
+ // (storybook iframe, a tweet preview, an embed) without getting clipped,
121
+ // but caps at the intended export width for screen-recording. `maxHeight`
122
+ // uses an absolute `dvh`-based value rather than `%` because `%` inside a
123
+ // flex container can cause the browser to clamp height without re-running
124
+ // aspect-ratio on width, producing a subtly wrong shape. An absolute cap
125
+ // leaves aspect-ratio fully in charge: once the height binds, width is
126
+ // re-derived correctly. `calc(100dvh - 8rem)` = viewport minus a typical
127
+ // host's vertical padding (e.g. Storybook's `p-8` = 4rem on each side),
128
+ // so the poster + padding fit within the viewport without ever producing
129
+ // scrollbars. Container queries tie all internal typography to the
130
+ // actual rendered width so headline/metadata scales along with the canvas.
131
+ const outerProps = {
132
+ // `text-midground` (not `text-foreground`) is the readable on-canvas
133
+ // color across every lens. `--foreground` is really the lens's inversion
134
+ // layer color: on dark lenses it has `fgOpacity: 0` and resolves to
135
+ // fully-transparent via `color-mix`, which would make text invisible.
136
+ // `--midground` always has opacity 1 and picks up each lens's accent.
137
+ className: cn(
138
+ 'text-midground relative overflow-hidden font-sans',
139
+ border && 'border border-current/25',
140
+ className
141
+ ),
142
+ style: {
143
+ aspectRatio: `${config.width} / ${config.height}`,
144
+ background: 'var(--background)',
145
+ containerType: 'inline-size' as const,
146
+ fontSize: `${(16 / config.width) * 100}cqi`,
147
+ maxHeight: 'calc(100dvh - 8rem)',
148
+ maxWidth: '100%',
149
+ width: `${config.width * scale}px`
150
+ },
151
+ ...rest
152
+ }
153
+
154
+ if (variant === 'vibe') {
155
+ return (
156
+ <div {...outerProps}>
157
+ <VibeContent
158
+ autoPlay={autoPlay}
159
+ channel={channel}
160
+ cornerMarks={cornerMarks}
161
+ signature={signature}
162
+ src={src}
163
+ tint={tint}
164
+ tintStrength={tintStrength}
165
+ />
166
+ </div>
167
+ )
168
+ }
169
+
170
+ const headlineLines = Array.isArray(headline) ? headline : [headline]
171
+
172
+ return (
173
+ <div {...outerProps} className={cn('flex flex-col', outerProps.className)}>
174
+ <DispatchHeader channel={channel} />
175
+
176
+ <div
177
+ className={cn(
178
+ 'relative min-h-0 min-w-0 flex-1',
179
+ resolvedLayout === 'split'
180
+ ? 'grid grid-cols-[3fr_2fr]'
181
+ : 'grid grid-rows-[3fr_2fr]'
182
+ )}
183
+ >
184
+ <div
185
+ className={cn(
186
+ 'relative overflow-hidden border-current/20',
187
+ resolvedLayout === 'split' ? 'border-r' : 'border-b'
188
+ )}
189
+ style={{ backgroundColor: 'var(--background)' }}
190
+ >
191
+ <ImageDistortion
192
+ autoPlay={autoPlay}
193
+ src={src}
194
+ tint={tint}
195
+ tintStrength={tintStrength}
196
+ />
197
+
198
+ {cornerMarks && (
199
+ <>
200
+ <CornerMark className="top-3 left-3" />
201
+ <CornerMark className="top-3 right-3" />
202
+ <CornerMark className="bottom-3 left-3" />
203
+ <CornerMark className="right-3 bottom-3" />
204
+ </>
205
+ )}
206
+
207
+ <ScanlineOverlay />
208
+
209
+ <Small className="absolute bottom-4 left-4 z-1 opacity-80">
210
+ Hermes Agent
211
+ </Small>
212
+ </div>
213
+
214
+ <aside className="relative flex min-w-0 flex-col justify-between gap-8 p-8">
215
+ <div className="flex flex-col gap-5">
216
+ {eyebrow && (
217
+ <div className="flex items-center gap-2">
218
+ <span className="bg-midground/80 h-px flex-1" />
219
+
220
+ <Small className="opacity-80">{eyebrow}</Small>
221
+ </div>
222
+ )}
223
+
224
+ {children ?? (
225
+ <>
226
+ <Typography
227
+ as="h1"
228
+ className="text-[2.75em] leading-[0.95] font-bold tracking-[-0.01em]"
229
+ expanded
230
+ >
231
+ {headlineLines.map((line, i) => (
232
+ <span className="block" key={`${line}-${i}`}>
233
+ {line}
234
+ </span>
235
+ ))}
236
+ </Typography>
237
+
238
+ {body && (
239
+ <p className="text-[1.0625em] leading-[1.5] tracking-normal normal-case opacity-60">
240
+ {body}
241
+ </p>
242
+ )}
243
+ </>
244
+ )}
245
+ </div>
246
+
247
+ {tags && tags.length > 0 && (
248
+ <ul className="flex flex-col gap-2 border-t border-current/15 pt-4">
249
+ {tags.map((tag, i) => (
250
+ <li
251
+ className="flex items-baseline justify-between gap-3"
252
+ key={`${tag}-${i}`}
253
+ >
254
+ <Small className="font-courier opacity-40">
255
+ {String(i + 1).padStart(3, '0')}
256
+ </Small>
257
+
258
+ <Small className="opacity-80">{tag}</Small>
259
+
260
+ <span className="mx-1 h-px flex-1 translate-y-[-3px] border-b border-dotted border-current/25" />
261
+
262
+ <Small className="font-courier opacity-40">
263
+ {String(i + 1).padStart(2, '0')}/
264
+ {String(tags.length).padStart(2, '0')}
265
+ </Small>
266
+ </li>
267
+ ))}
268
+ </ul>
269
+ )}
270
+ </aside>
271
+ </div>
272
+
273
+ <footer className="flex items-center justify-between gap-4 border-t border-current/20 px-6 py-3">
274
+ <Small className="opacity-70">
275
+ {signature}
276
+
277
+ <Blink />
278
+ </Small>
279
+
280
+ <Small className="font-courier opacity-40">{seal}</Small>
281
+ </footer>
282
+ </div>
283
+ )
284
+ }
285
+
286
+ function DispatchHeader({ channel }: { channel: React.ReactNode }) {
287
+ const clock = useUtcClock()
288
+
289
+ return (
290
+ <header className="flex items-center justify-between gap-4 border-b border-current/20 px-6 py-3">
291
+ <div className="flex items-center gap-3">
292
+ <span className="bg-midground size-2 rounded-sm opacity-70" />
293
+
294
+ <Small className="opacity-70">{channel}</Small>
295
+ </div>
296
+
297
+ <div className="flex items-center gap-4">
298
+ <ChannelDot />
299
+
300
+ <Small className="font-courier opacity-50">{clock} UTC</Small>
301
+ </div>
302
+ </header>
303
+ )
304
+ }
305
+
306
+ interface VibeContentProps {
307
+ autoPlay: AutoPlayPattern
308
+ channel: React.ReactNode
309
+ cornerMarks: boolean
310
+ signature: React.ReactNode
311
+ src: string
312
+ tint?: string
313
+ tintStrength?: { active: number; inactive: number }
314
+ }
315
+
316
+ function VibeContent({
317
+ autoPlay,
318
+ channel,
319
+ cornerMarks,
320
+ signature,
321
+ src,
322
+ tint,
323
+ tintStrength
324
+ }: VibeContentProps) {
325
+ // Absolute-inset-0 guarantees this fills the poster even when the outer
326
+ // container uses aspect-ratio-derived height in a browser that doesn't
327
+ // propagate that as a definite height for percentage-based children.
328
+ return (
329
+ <div className="absolute inset-0">
330
+ <ImageDistortion
331
+ autoPlay={autoPlay}
332
+ src={src}
333
+ tint={tint}
334
+ tintStrength={tintStrength}
335
+ />
336
+
337
+ {cornerMarks && (
338
+ <>
339
+ <CornerMark className="top-5 left-5" />
340
+ <CornerMark className="top-5 right-5" />
341
+ <CornerMark className="bottom-5 left-5" />
342
+ <CornerMark className="right-5 bottom-5" />
343
+ </>
344
+ )}
345
+
346
+ <ScanlineOverlay />
347
+
348
+ {channel && (
349
+ <Small className="absolute top-5 left-10 z-1 text-[0.75em] opacity-70">
350
+ {channel}
351
+ </Small>
352
+ )}
353
+
354
+ <Small className="absolute right-10 bottom-5 z-1 text-[0.75em] opacity-80">
355
+ {signature}
356
+ </Small>
357
+ </div>
358
+ )
359
+ }
360
+
361
+ export type PosterAspect =
362
+ | 'landscape'
363
+ | 'portrait'
364
+ | 'square'
365
+ | 'story'
366
+ | 'wide'
367
+
368
+ export type PosterVariant = 'dispatch' | 'vibe'
369
+
370
+ export interface PosterProps {
371
+ /** Output aspect ratio. Picks sensible defaults for common social formats. */
372
+ aspect?: PosterAspect
373
+ /** Distortion choreography pattern. Default: `'slash'`. */
374
+ autoPlay?: AutoPlayPattern
375
+ /** (`dispatch` only) Descriptive copy under the headline. */
376
+ body?: React.ReactNode
377
+ /** Show the thin outer frame around the poster. Default `true`. */
378
+ border?: boolean
379
+ /** Tiny broadcast-station label. Optional in `vibe`; shown in header in `dispatch`. */
380
+ channel?: React.ReactNode
381
+ /** (`dispatch` only) Override the sidebar content (takes precedence over headline/body). */
382
+ children?: React.ReactNode
383
+ className?: string
384
+ /** Show the small `+` die-line registration marks in the image corners. Default `true`. */
385
+ cornerMarks?: boolean
386
+ /** (`dispatch` only) Small tagline above the headline. */
387
+ eyebrow?: React.ReactNode
388
+ /** (`dispatch` only) Big expanded-typography headline. Pass an array of strings to stack lines. */
389
+ headline?: string[] | string
390
+ /** (`dispatch` only) Force stacked vs split layout. Default inferred from `aspect`. */
391
+ layout?: 'split' | 'stacked'
392
+ /** Render scale. 1 = full canvas (1080px+ base width). */
393
+ scale?: number
394
+ /** (`dispatch` only) Small legal / signature line at the bottom-right. */
395
+ seal?: React.ReactNode
396
+ /**
397
+ * Signature mark. In `vibe` this is the small "Hermes Agent" overlay in the
398
+ * bottom-right. In `dispatch` this is the URL / CTA in the footer.
399
+ */
400
+ signature?: React.ReactNode
401
+ /** Override the poster image. Defaults to the Hermes "filler-bg0" asset. */
402
+ src?: string
403
+ /** (`dispatch` only) Ranked list of features / pricing tiers rendered as a numbered sidebar list. */
404
+ tags?: string[]
405
+ /** Shader tint overlay. Great for tier-colored variants. */
406
+ tint?: string
407
+ /** Active / inactive tint strength — defaults match `ImageDistortion`. */
408
+ tintStrength?: { active: number; inactive: number }
409
+ /** Layout variant. `'vibe'` (default) is full-bleed image; `'dispatch'` is the broadcast-card with sidebar copy. */
410
+ variant?: PosterVariant
411
+ }
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Button } from './button'
5
+ import { Progress } from './progress'
6
+
7
+ const meta: Meta<typeof Progress> = {
8
+ args: { animate: true, speed: 0.4, value: 42 },
9
+ component: Progress,
10
+ title: 'Components/Progress'
11
+ }
12
+
13
+ export default meta
14
+
15
+ type Story = StoryObj<typeof Progress>
16
+
17
+ export const Playground: Story = {
18
+ args: { children: '42%' }
19
+ }
20
+
21
+ export const Stages: Story = {
22
+ render: () => (
23
+ <div className="flex flex-col gap-3">
24
+ <Progress value={15} />
25
+ <Progress value={42}>42%</Progress>
26
+ <Progress value={75}>75%</Progress>
27
+ <Progress value={100}>Complete</Progress>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ export const Interactive: Story = {
33
+ render: () => {
34
+ const [value, setValue] = useState(42)
35
+
36
+ return (
37
+ <div className="flex flex-col gap-4">
38
+ <Progress value={value}>{value}%</Progress>
39
+
40
+ <div className="flex gap-2">
41
+ <Button onClick={() => setValue(v => Math.max(0, v - 10))}>-10</Button>
42
+
43
+ <Button onClick={() => setValue(v => Math.min(100, v + 10))}>+10</Button>
44
+ </div>
45
+ </div>
46
+ )
47
+ }
48
+ }
@@ -0,0 +1,56 @@
1
+ import { cn } from '../../utils'
2
+
3
+ import { Typography, type TypographyProps } from './typography'
4
+
5
+ export const Progress = ({
6
+ animate = true,
7
+ barProps,
8
+ children,
9
+ className,
10
+ speed = 0.4,
11
+ value,
12
+ ...props
13
+ }: ProgressProps) => (
14
+ <div
15
+ className={cn(
16
+ 'relative flex min-h-[2.3rem] min-w-0 flex-1 items-stretch overflow-hidden',
17
+ className
18
+ )}
19
+ {...props}
20
+ >
21
+ <Typography
22
+ {...barProps}
23
+ className={cn(
24
+ 'shrink-0 translate-y-0.5 truncate py-2',
25
+ 'bg-midground/20',
26
+ children ? 'px-2' : 'px-0',
27
+ barProps?.className
28
+ )}
29
+ mono
30
+ style={{
31
+ ...(animate && { transition: `width ${speed}s steps(10, end)` }),
32
+ width: `${value}%`,
33
+ ...barProps?.style
34
+ }}
35
+ >
36
+ {children}
37
+ </Typography>
38
+
39
+ <div
40
+ className="flex-1"
41
+ style={
42
+ {
43
+ '--x': '.5rem',
44
+ backgroundImage: `repeating-linear-gradient(to right, transparent 0 var(--x), color-mix(in srgb, var(--color-midground) 17%, transparent) var(--x) calc(var(--x) + 1px))`
45
+ } as React.CSSProperties
46
+ }
47
+ />
48
+ </div>
49
+ )
50
+
51
+ interface ProgressProps extends React.ComponentProps<'div'> {
52
+ animate?: boolean
53
+ barProps?: TypographyProps<'span'>
54
+ speed?: number
55
+ value: number
56
+ }