@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,217 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import fillerBg from '../../assets/filler-bg0.webp'
4
+ import { TierCard } from './tier-card'
5
+
6
+ const SCOUT_SRC = fillerBg.src ?? (fillerBg as unknown as string)
7
+
8
+ // Same tier palette referenced in `Poster.stories.tsx` and originally from
9
+ // `nous-account-service/src/app/manage-subscription/_components/TierCard.tsx`.
10
+ // Keep the two in sync so a design review can compare the card layout and
11
+ // the bare poster side-by-side.
12
+ const TIERS = [
13
+ {
14
+ bullets: ['Free models only'],
15
+ label: 'Scout',
16
+ price: { primary: 'Free', primarySuffix: '/mo' },
17
+ src: SCOUT_SRC,
18
+ tint: '#88ccaa'
19
+ },
20
+ {
21
+ bullets: ['300+ models', 'Hosted tool usage', '$5 monthly credits'],
22
+ label: 'Visor',
23
+ price: { primary: '$5', primarySuffix: '/mo' },
24
+ src: '/img/hermes-2.png',
25
+ tint: '#99bbdd'
26
+ },
27
+ {
28
+ bullets: [
29
+ '300+ models',
30
+ 'Hosted tool usage',
31
+ '$20 monthly credits',
32
+ '$40 rollover cap'
33
+ ],
34
+ label: 'Angel',
35
+ price: { primary: '$20', primarySuffix: '/mo' },
36
+ src: '/img/hermes-3.jpg',
37
+ tint: '#ccaa88'
38
+ },
39
+ {
40
+ bullets: [
41
+ '300+ models',
42
+ 'Hosted tool usage',
43
+ '$50 monthly credits',
44
+ '$100 rollover cap'
45
+ ],
46
+ label: 'Herald',
47
+ price: { primary: '$50', primarySuffix: '/mo' },
48
+ src: '/img/hermes-4.png',
49
+ tint: '#dd8899'
50
+ },
51
+ {
52
+ bullets: [
53
+ '300+ models',
54
+ 'Hosted tool usage',
55
+ '$150 monthly credits',
56
+ '$300 rollover cap'
57
+ ],
58
+ label: 'Muse',
59
+ price: { primary: '$200', primarySuffix: '/mo' },
60
+ src: '/img/hermes-1.png',
61
+ tint: '#ccaa88'
62
+ }
63
+ ] as const
64
+
65
+ const HIGHEST_OVERLAY = {
66
+ overlay: 'rgba(180, 30, 20, 1)',
67
+ tint: '#ff4444',
68
+ tintStrength: { active: 0.55, inactive: 0.35 }
69
+ }
70
+
71
+ const meta = {
72
+ args: {
73
+ bullets: [...TIERS[2].bullets],
74
+ image: TIERS[2].src,
75
+ price: TIERS[2].price,
76
+ tint: TIERS[2].tint,
77
+ title: TIERS[2].label
78
+ },
79
+ argTypes: {
80
+ badge: { control: 'text' },
81
+ bullets: { control: 'object' },
82
+ className: { table: { disable: true } },
83
+ image: { control: 'text' },
84
+ isCurrent: { control: 'boolean' },
85
+ onSelect: { action: 'select' },
86
+ overlay: { control: 'color' },
87
+ price: { control: 'object' },
88
+ selected: { control: 'boolean' },
89
+ tint: { control: 'color' },
90
+ tintStrength: { control: 'object' },
91
+ title: { control: 'text' }
92
+ },
93
+ component: TierCard,
94
+ decorators: [
95
+ (Story, context) => {
96
+ // Stories that provide their own layout (e.g. `Row`) opt in via the
97
+ // `tierCardRaw` param. Everything else gets the compact 16rem preview
98
+ // frame on top of the dark background lens.
99
+ if (context.parameters?.tierCardRaw) {
100
+ return <Story />
101
+ }
102
+
103
+ return (
104
+ <div
105
+ className="bg-background flex items-center justify-center p-8"
106
+ style={{ minHeight: '100dvh' }}
107
+ >
108
+ <div className="w-[22rem]">
109
+ <Story />
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+ ],
115
+ parameters: {
116
+ docs: {
117
+ description: {
118
+ component:
119
+ 'Selectable subscription-tier card. Fully presentational: the consumer owns the data (tier schema, price formatting, imagery, tints). Toggle `selected` to see the `.arc-border` shimmer and `mix-blend-mode: plus-lighter` lift on the headline / price.'
120
+ }
121
+ },
122
+ layout: 'fullscreen'
123
+ },
124
+ title: 'Components/TierCard'
125
+ } satisfies Meta<typeof TierCard>
126
+
127
+ export default meta
128
+
129
+ type Story = StoryObj<typeof meta>
130
+
131
+ /** Default resting state. Hover to preview the arc-border shimmer. */
132
+ export const Idle: Story = {}
133
+
134
+ /** Selected state — arc-border, active distortion, lifted text. */
135
+ export const Selected: Story = {
136
+ args: { selected: true }
137
+ }
138
+
139
+ /** Current plan, not selected — subtle midground border hint. */
140
+ export const Current: Story = {
141
+ args: { badge: '(current)', isCurrent: true }
142
+ }
143
+
144
+ /** Current plan AND selected — both treatments compose. */
145
+ export const CurrentSelected: Story = {
146
+ args: { badge: '(current)', isCurrent: true, selected: true }
147
+ }
148
+
149
+ /** Highest tier red-overlay treatment. */
150
+ export const HighestTier: Story = {
151
+ args: {
152
+ ...HIGHEST_OVERLAY,
153
+ bullets: [...TIERS[3].bullets],
154
+ image: TIERS[3].src,
155
+ price: { primary: '$200', primarySuffix: '/mo' },
156
+ selected: true,
157
+ title: 'Sovereign'
158
+ }
159
+ }
160
+
161
+ /** Struck-through comparison price (e.g. first-payment discount). */
162
+ export const WithDiscount: Story = {
163
+ args: {
164
+ bullets: [...TIERS[2].bullets],
165
+ image: TIERS[2].src,
166
+ price: {
167
+ primary: '$10',
168
+ primarySuffix: 'first payment',
169
+ secondary: '$20',
170
+ secondarySuffix: '/mo'
171
+ },
172
+ tint: TIERS[2].tint,
173
+ title: TIERS[2].label
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Full 5-card row approximating the live manage-subscription page, with
179
+ * the highest tier carrying the red overlay. Click any card to toggle
180
+ * selection — mirrors the interaction model in the consumer app.
181
+ */
182
+ export const Row: StoryObj = {
183
+ // Opt out of the compact single-card wrapper (see the meta decorator)
184
+ // and supply a full-width grid instead.
185
+ decorators: [
186
+ Story => (
187
+ <div
188
+ className="bg-background flex items-center justify-center p-10"
189
+ style={{ minHeight: '100dvh' }}
190
+ >
191
+ <div className="grid w-full max-w-[90rem] grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
192
+ <Story />
193
+ </div>
194
+ </div>
195
+ )
196
+ ],
197
+ parameters: { layout: 'fullscreen', tierCardRaw: true },
198
+ render: () => (
199
+ <>
200
+ {TIERS.map((tier, i) => {
201
+ const isHighest = i === TIERS.length - 1
202
+
203
+ return (
204
+ <TierCard
205
+ bullets={[...tier.bullets]}
206
+ image={tier.src}
207
+ key={tier.label}
208
+ price={tier.price}
209
+ selected={i === 2}
210
+ title={tier.label}
211
+ {...(isHighest ? HIGHEST_OVERLAY : { tint: tier.tint })}
212
+ />
213
+ )
214
+ })}
215
+ </>
216
+ )
217
+ }
@@ -0,0 +1,190 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../utils'
4
+
5
+ import { ImageDistortion } from './image-distortion'
6
+ import { Typography } from './typography'
7
+ import { Small } from './typography/small'
8
+
9
+ /**
10
+ * Selectable tier / pricing card. Full-bleed distorted image background,
11
+ * readable overlay text, and an animated `.arc-border` shimmer on the
12
+ * selected state. Fully presentational — the consumer owns the data
13
+ * (tier schema, price formatting, tier imagery / tints).
14
+ *
15
+ * Visual states:
16
+ * - `selected`: brightens the distortion, activates `.arc-border`, and
17
+ * composites the headline / price with `mix-blend-mode: plus-lighter`
18
+ * so the text lifts off the image regardless of tint.
19
+ * - `isCurrent`: subtle midground-tinted border hint (suppressed when
20
+ * `selected` wins).
21
+ * - `overlay`: optional top-layer color blended with `mix-blend-mode:
22
+ * color` — used for the "highest tier" red treatment on top of any
23
+ * base tint.
24
+ */
25
+ export function TierCard({
26
+ badge,
27
+ bullets,
28
+ className,
29
+ image,
30
+ isCurrent = false,
31
+ onSelect,
32
+ overlay,
33
+ price,
34
+ selected = false,
35
+ tint,
36
+ tintStrength,
37
+ title
38
+ }: TierCardProps) {
39
+ return (
40
+ <button
41
+ className={cn(
42
+ 'group relative flex w-full cursor-pointer flex-col border border-current/20',
43
+ 'text-left transition-colors duration-300',
44
+ selected && 'border-midground/60',
45
+ isCurrent && !selected && 'border-midground/30',
46
+ className
47
+ )}
48
+ onClick={onSelect}
49
+ type="button"
50
+ >
51
+ <span
52
+ aria-hidden
53
+ className={cn(
54
+ 'arc-border transition-opacity duration-200',
55
+ selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
56
+ )}
57
+ />
58
+
59
+ <div
60
+ className="relative aspect-[3/4] min-h-0 w-full flex-1 overflow-hidden"
61
+ style={{ backgroundColor: 'var(--background)' }}
62
+ >
63
+ <ImageDistortion
64
+ active={selected}
65
+ src={image}
66
+ tint={tint}
67
+ tintStrength={tintStrength}
68
+ />
69
+
70
+ {overlay && (
71
+ <div
72
+ className="pointer-events-none absolute inset-0"
73
+ style={{ backgroundColor: overlay, mixBlendMode: 'color' }}
74
+ />
75
+ )}
76
+
77
+ <div className="pointer-events-none absolute inset-0 z-[1] flex flex-col justify-between p-3">
78
+ <div className="flex flex-col gap-0.5">
79
+ <Typography variant="sm"
80
+ className={cn(
81
+ 'block drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)] text-[1.2rem]',
82
+ 'transition-colors',
83
+ selected && 'text-midground'
84
+ )}
85
+ style={selected ? { mixBlendMode: 'plus-lighter' } : undefined}
86
+ >
87
+ {title}
88
+ {badge && <span className="ml-1 opacity-50">{badge}</span>}
89
+ </Typography>
90
+
91
+ {price.secondary ? (
92
+ <>
93
+ <Typography
94
+ className="block text-md line-through opacity-50 drop-shadow-[0_1px_3px_rgba(0,0,0,0.6)]"
95
+ expanded
96
+ style={{ mixBlendMode: 'plus-lighter' }}
97
+ >
98
+ {price.secondary}
99
+ {price.secondarySuffix && (
100
+ <span className="text-[1rem]">
101
+ {price.secondarySuffix}
102
+ </span>
103
+ )}
104
+ </Typography>
105
+
106
+ <Typography
107
+ className="block text-xl font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,0.6)]"
108
+ expanded
109
+ style={{ mixBlendMode: 'plus-lighter' }}
110
+ >
111
+ {price.primary}
112
+ {price.primarySuffix && (
113
+ <span className="text-[1rem] opacity-60">
114
+ {' '}
115
+ {price.primarySuffix}
116
+ </span>
117
+ )}
118
+ </Typography>
119
+ </>
120
+ ) : (
121
+ <Typography
122
+ className="block text-xl font-bold drop-shadow-[0_1px_3px_rgba(0,0,0,0.6)]"
123
+ expanded
124
+ style={{ mixBlendMode: 'plus-lighter' }}
125
+ >
126
+ {price.primary}
127
+ {price.primarySuffix && (
128
+ <span className="text-[1rem] opacity-60">
129
+ {price.primarySuffix}
130
+ </span>
131
+ )}
132
+ </Typography>
133
+ )}
134
+ </div>
135
+
136
+ {bullets.length > 0 && (
137
+ <ul className="flex flex-col gap-1">
138
+ {bullets.map((bullet, i) => (
139
+ <li
140
+ className={cn(
141
+ 'font-courier text-display text-[1rem] leading-tight tracking-tight',
142
+ 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)]',
143
+ )}
144
+ key={typeof bullet === 'string' ? bullet : i}
145
+ >
146
+ · {bullet}
147
+ </li>
148
+ ))}
149
+ </ul>
150
+ )}
151
+ </div>
152
+ </div>
153
+ </button>
154
+ )
155
+ }
156
+
157
+ export interface TierCardPrice {
158
+ /** Headline price, e.g. `"$20"` or `"Free"`. */
159
+ primary: string
160
+ /** Small suffix rendered after `primary`, e.g. `"/mo"` or `"first payment"`. */
161
+ primarySuffix?: string
162
+ /** Optional struck-through comparison price rendered above `primary`, e.g. `"$30"`. */
163
+ secondary?: string
164
+ /** Small suffix rendered after `secondary`. */
165
+ secondarySuffix?: string
166
+ }
167
+
168
+ export interface TierCardProps {
169
+ /** Small annotation after the title, e.g. `"(current)"`. */
170
+ badge?: React.ReactNode
171
+ /** Feature list rendered under the price. */
172
+ bullets: React.ReactNode[]
173
+ className?: string
174
+ /** Background image URL. */
175
+ image: string
176
+ /** Applies the "current plan" border hint when not `selected`. */
177
+ isCurrent?: boolean
178
+ onSelect?: () => void
179
+ /** Color blended with `mix-blend-mode: color` over the image (used for the highest-tier red treatment). */
180
+ overlay?: string
181
+ price: TierCardPrice
182
+ /** Applies selected chrome (arc-border shimmer, active distortion, plus-lighter text blend). */
183
+ selected?: boolean
184
+ /** Shader tint passed through to `ImageDistortion`. */
185
+ tint?: string
186
+ /** Active / inactive tint strength passed through to `ImageDistortion`. */
187
+ tintStrength?: { active: number; inactive: number }
188
+ /** Tier name / headline. */
189
+ title: React.ReactNode
190
+ }
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Suspense } from 'react'
3
+
4
+ import { TV } from './tv'
5
+
6
+ const meta = {
7
+ component: TV,
8
+ parameters: {
9
+ docs: {
10
+ description: {
11
+ component:
12
+ 'Animated WebGL brush inside an SVG television frame. Renders a fragment shader, so it only makes sense on the client.'
13
+ }
14
+ }
15
+ },
16
+ title: 'Components/TV'
17
+ } satisfies Meta<typeof TV>
18
+
19
+ export default meta
20
+
21
+ type Story = StoryObj<typeof meta>
22
+
23
+ export const Default: Story = {
24
+ render: () => (
25
+ <Suspense>
26
+ <TV className="h-64 w-64" />
27
+ </Suspense>
28
+ )
29
+ }
30
+
31
+ export const Large: Story = {
32
+ render: () => (
33
+ <Suspense>
34
+ <TV className="h-[28rem] w-[28rem]" />
35
+ </Suspense>
36
+ )
37
+ }
@@ -0,0 +1,257 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ const VERT = /* glsl */ `attribute vec2 a;void main(){gl_Position=vec4(a,0,1);}`
6
+
7
+ const FRAG = /* glsl */ `precision highp float;
8
+ uniform float t;
9
+ uniform vec2 r;
10
+
11
+ const float FBM_STR = .08;
12
+
13
+ float h(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
14
+
15
+ float n2(vec2 p) {
16
+ vec2 i = floor(p), f = fract(p);
17
+ f = f * f * (3. - 2. * f);
18
+
19
+ return mix(
20
+ mix(h(i), h(i + vec2(1, 0)), f.x),
21
+ mix(h(i + vec2(0, 1)), h(i + vec2(1, 1)), f.x),
22
+ f.y
23
+ );
24
+ }
25
+
26
+ float fbm(vec2 p) {
27
+ float v = 0., a = .5;
28
+
29
+ for (int i = 0; i < 4; i++) {
30
+ v += a * n2(p);
31
+ p *= 2.1;
32
+ a *= .45;
33
+ }
34
+
35
+ return v;
36
+ }
37
+
38
+ float drift(float speed, float s) {
39
+ return fract(t * speed + s + .02 * sin(t * .4 + s * 3.));
40
+ }
41
+
42
+ float brushAt(vec2 uv, float y, float th, float s) {
43
+ float hw = .34 + .08 * h(vec2(s, 77.));
44
+ float cx = .5;
45
+ float xn = (uv.x - (cx - hw)) / (2. * hw);
46
+ float env = smoothstep(0., .03, xn) * smoothstep(1., .97, xn);
47
+ float localTh = th * env;
48
+
49
+ if (localTh < .002) return 0.;
50
+
51
+ float morph = floor(t * 8.) * .7 + s;
52
+ float top = y - localTh * .5 + fbm(vec2(uv.x * 6., morph)) * FBM_STR;
53
+ float bot = y + localTh * .5 - fbm(vec2(uv.x * 6., morph + 30.)) * FBM_STR;
54
+ float x0 = cx - hw + fbm(vec2(uv.y * 8., morph + 60.)) * FBM_STR;
55
+ float x1 = cx + hw - fbm(vec2(uv.y * 8., morph + 90.)) * FBM_STR;
56
+
57
+ float dMin = min(min(uv.y - top, bot - uv.y), min(uv.x - x0, x1 - uv.x));
58
+
59
+ float bristle = n2(vec2(uv.x * 60., uv.y * 8. + s)) * .4
60
+ + n2(vec2(uv.x * 25., (uv.y - y) * 120. + s)) * .35
61
+ + n2(vec2(uv.x * 90., uv.y * 3. + s * 2.)) * .25;
62
+
63
+ float eaten = smoothstep(.03, 0., dMin) * (1. - smoothstep(.2, .5, bristle));
64
+
65
+ return clamp(smoothstep(0., .003, dMin) * (1. - eaten), 0., 1.);
66
+ }
67
+
68
+ void main() {
69
+ vec2 uv = gl_FragCoord.xy / r;
70
+ uv = vec2(uv.x * cos(.095) - uv.y * sin(.095), uv.x * sin(.095) + uv.y * cos(.095));
71
+ uv += vec2(fbm(uv * 4. + t * .06), fbm(uv * 4. + 8. + t * .05)) * .012;
72
+
73
+ vec3 c = vec3(.992, .992, .051);
74
+
75
+ float smScroll = -drift(.04, 5.) * 2.;
76
+ float sm = 0.;
77
+
78
+ for (int i = 0; i < 20; i++) {
79
+ sm = max(sm, brushAt(uv, mod(float(i) * .1 + smScroll, 2.) - .5, .04, float(i) + 10.));
80
+ }
81
+
82
+ float d1 = drift(.15, 1.), d2 = drift(.15, 1.37), d3 = drift(.15, 1.58), d4 = drift(.15, 1.82);
83
+ float big = max(
84
+ max(brushAt(uv, 1.1 - d1 * 1.4, .28, 1.), brushAt(uv, 1.1 - d2 * 1.4, .18, 2.)),
85
+ max(brushAt(uv, 1.1 - d3 * 1.4, .3, 3.), brushAt(uv, 1.1 - d4 * 1.4, .15, 4.))
86
+ );
87
+
88
+ c = mix(c, vec3(0.), clamp(max(sm, big), 0., 1.));
89
+ c *= .94 + .06 * sin(uv.y * r.y * 6.283);
90
+
91
+ vec2 raw = gl_FragCoord.xy / r;
92
+ float dx = min(raw.x - .22, .90 - raw.x);
93
+ float dy = min(raw.y - .29, .86 - raw.y);
94
+ float cycle = floor(t * .4);
95
+ float edge = mix(smoothstep(.22, 0., max(min(dx, dy), 0.)), 1., step(.75, h(vec2(cycle, 13.))))
96
+ * smoothstep(.85, 1., sin(t * 2.5) * .5 + .5)
97
+ * (.7 + .3 * h(vec2(cycle, 7.)));
98
+
99
+ float scanY = floor(gl_FragCoord.y);
100
+ float rowNoise = h(vec2(scanY, floor(t * 30.)));
101
+ c *= 1. - edge * max(step(.45, rowNoise), step(.3, h(vec2(gl_FragCoord.x + scanY * 7., floor(t * 45.)))) * step(.2, rowNoise));
102
+
103
+ gl_FragColor = vec4(clamp(c, 0., 1.), 1.);
104
+ }`
105
+
106
+ function useGL(ref: React.RefObject<HTMLCanvasElement | null>) {
107
+ const raf = useRef(0)
108
+
109
+ useEffect(() => {
110
+ const c = ref.current
111
+
112
+ if (!c) {
113
+ return
114
+ }
115
+
116
+ const gl = c.getContext('webgl')
117
+
118
+ if (!gl) {
119
+ return
120
+ }
121
+
122
+ const sh = (type: number, src: string) => {
123
+ const s = gl.createShader(type)!
124
+ gl.shaderSource(s, src)
125
+ gl.compileShader(s)
126
+
127
+ return s
128
+ }
129
+
130
+ const p = gl.createProgram()!
131
+ gl.attachShader(p, sh(gl.VERTEX_SHADER, VERT))
132
+ gl.attachShader(p, sh(gl.FRAGMENT_SHADER, FRAG))
133
+ gl.linkProgram(p)
134
+ gl.useProgram(p)
135
+
136
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
137
+ gl.bufferData(
138
+ gl.ARRAY_BUFFER,
139
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
140
+ gl.STATIC_DRAW
141
+ )
142
+
143
+ const a = gl.getAttribLocation(p, 'a')
144
+ gl.enableVertexAttribArray(a)
145
+ gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0)
146
+
147
+ const uT = gl.getUniformLocation(p, 't')
148
+ const uR = gl.getUniformLocation(p, 'r')
149
+
150
+ const resize = () => {
151
+ const rect = c.getBoundingClientRect()
152
+ const dpr = Math.min(devicePixelRatio, 2)
153
+
154
+ c.width = rect.width * dpr
155
+ c.height = rect.height * dpr
156
+
157
+ gl.viewport(0, 0, c.width, c.height)
158
+ }
159
+
160
+ resize()
161
+
162
+ const ro = new ResizeObserver(resize)
163
+ ro.observe(c)
164
+
165
+ const t0 = performance.now()
166
+
167
+ let visible = !document.hidden
168
+ let inView = true
169
+ let raf2 = 0
170
+
171
+ const tick = () => {
172
+ gl.uniform1f(uT, (performance.now() - t0) / 1e3)
173
+ gl.uniform2f(uR, c.width, c.height)
174
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
175
+
176
+ raf2 = requestAnimationFrame(tick)
177
+ }
178
+
179
+ const start = () => {
180
+ if (visible && inView && !raf2) {
181
+ raf2 = requestAnimationFrame(tick)
182
+ }
183
+ }
184
+
185
+ const stop = () => {
186
+ if (raf2) {
187
+ cancelAnimationFrame(raf2)
188
+ raf2 = 0
189
+ }
190
+ }
191
+
192
+ const onVisibility = () => {
193
+ visible = !document.hidden
194
+ visible ? start() : stop()
195
+ }
196
+
197
+ const io = new IntersectionObserver(
198
+ entries => {
199
+ inView = entries.some(e => e.isIntersecting)
200
+ inView ? start() : stop()
201
+ },
202
+ { threshold: 0 }
203
+ )
204
+
205
+ io.observe(c)
206
+ document.addEventListener('visibilitychange', onVisibility)
207
+
208
+ start()
209
+ raf.current = raf2
210
+
211
+ return () => {
212
+ stop()
213
+ io.disconnect()
214
+ document.removeEventListener('visibilitychange', onVisibility)
215
+ ro.disconnect()
216
+ }
217
+ }, [ref])
218
+ }
219
+
220
+ export function TV({ className }: { className?: string }) {
221
+ const canvasRef = useRef<HTMLCanvasElement>(null)
222
+ useGL(canvasRef)
223
+
224
+ return (
225
+ <div className={['relative', className].filter(Boolean).join(' ')}>
226
+ <svg className="relative h-full w-full" fill="none" viewBox="0 0 210 173">
227
+ <path
228
+ d="M30.8342 2.44471 6.08268 36.683c-.24437.338-.38254.7412-.39689 1.158L1.57754 157.126c-.03891 1.129.82339 2.087 1.95096 2.167l162.4835 11.463c.433.031.866-.074 1.238-.3l35.718-21.69c.607-.369.986-1.02 1.008-1.73l4.102-130.9871c.035-1.1269-.826-2.0806-1.951-2.1604L32.6847 1.58029c-.7248-.05144-1.4247.27551-1.8505.86442Z"
229
+ fill="#FDFD0D"
230
+ stroke="#FDFD0D"
231
+ strokeWidth="3.15"
232
+ />
233
+
234
+ <path
235
+ d="M203.09 17.1483 35.6844 5.83395l-4.2 121.94805 168.4906 13.076z"
236
+ fill="#000"
237
+ stroke="#FDFD0D"
238
+ strokeWidth="4.2"
239
+ />
240
+
241
+ <path
242
+ d="M190.491 29.7483 48.2859 18.434l-4.2 98.848 143.2901 10.976z"
243
+ fill="#FDFD0D"
244
+ />
245
+ </svg>
246
+
247
+ <canvas
248
+ className="absolute inset-0 h-full w-full"
249
+ ref={canvasRef}
250
+ style={{
251
+ clipPath:
252
+ 'polygon(23% 10.65%, 90.71% 17.2%, 89.23% 74.13%, 20.99% 67.79%)'
253
+ }}
254
+ />
255
+ </div>
256
+ )
257
+ }