@boyernick/standard-ui-react 0.1.1-canary.9 → 0.2.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 (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
@@ -0,0 +1,97 @@
1
+ import type {
2
+ LifelineEvent,
3
+ LifelineLegendItem,
4
+ LifelineMarker,
5
+ } from "./types"
6
+
7
+ export const LIFELINE_CURRENT_YEAR = 2026
8
+
9
+ export type LifelineMilestone = Omit<LifelineMarker, "year">
10
+ export type LifelineMilestones = Record<number, LifelineMilestone>
11
+
12
+ export interface LifelineBirthday {
13
+ month: number
14
+ day: number
15
+ }
16
+
17
+ export interface LifelineRecord {
18
+ slug: string
19
+ name: string
20
+ birthYear: number
21
+ birthday?: LifelineBirthday
22
+ /** Last year on the timeline. Omit for living people. */
23
+ endYear?: number
24
+ description: string
25
+ /** People-legend labels; defaults to Mentors / Met in person. */
26
+ legend?: LifelineLegendItem[]
27
+ markers: LifelineMarker[]
28
+ }
29
+
30
+ interface DefineLifelineInput {
31
+ slug: string
32
+ name: string
33
+ birthYear: number
34
+ birthday?: LifelineBirthday
35
+ endYear?: number
36
+ description: string
37
+ legend?: LifelineLegendItem[]
38
+ milestones: LifelineMilestones
39
+ }
40
+
41
+ /**
42
+ * Translated event texts keyed by year, aligned by index with the
43
+ * source milestone's events. Only the text is swapped — images,
44
+ * effects, mentors, and structure stay single-sourced.
45
+ */
46
+ export type LifelineTextOverrides = Record<number, string[]>
47
+
48
+ export function localizeLifelineMarkers(
49
+ markers: LifelineMarker[],
50
+ texts: LifelineTextOverrides,
51
+ ): LifelineMarker[] {
52
+ return markers.map((marker) => {
53
+ const translated = texts[marker.year]
54
+ if (!translated) return marker
55
+
56
+ const events: LifelineEvent[] = marker.events.map((event, index) => {
57
+ const text = translated[index]
58
+ if (text === undefined) return event
59
+ if (typeof event === "string") return text
60
+ if (Array.isArray(event)) return event
61
+ return { ...event, text }
62
+ })
63
+
64
+ return { ...marker, events }
65
+ })
66
+ }
67
+
68
+ export function defineLifeline(input: DefineLifelineInput): LifelineRecord {
69
+ const { milestones, ...record } = input
70
+ const lastYear = input.endYear ?? LIFELINE_CURRENT_YEAR
71
+ const markers: LifelineMarker[] = []
72
+ const today = new Date()
73
+
74
+ for (let year = input.birthYear; year <= lastYear; year++) {
75
+ const milestone = milestones[year]
76
+ const birthdayPending =
77
+ year === LIFELINE_CURRENT_YEAR &&
78
+ input.birthday &&
79
+ (today.getMonth() + 1 < input.birthday.month ||
80
+ (today.getMonth() + 1 === input.birthday.month &&
81
+ today.getDate() < input.birthday.day))
82
+ const age = birthdayPending ? year - input.birthYear - 1 : undefined
83
+
84
+ markers.push(
85
+ milestone
86
+ ? { year, ...(age !== undefined && { age }), ...milestone }
87
+ : {
88
+ id: `year-${year}`,
89
+ year,
90
+ events: [],
91
+ ...(age !== undefined && { age }),
92
+ },
93
+ )
94
+ }
95
+
96
+ return { ...record, markers }
97
+ }
@@ -0,0 +1,204 @@
1
+ "use client"
2
+
3
+ import { useMemo, type CSSProperties } from "react"
4
+ import { cn } from "../lib/cn"
5
+ import { focusRing, focusRingBorder } from "../lib/focus"
6
+ import {
7
+ LifelineStickyLabels,
8
+ LIFELINE_STICKY_SHIELD_WIDTH,
9
+ } from "./lifeline-labels"
10
+ import { LifelineMarkerColumn } from "./lifeline-marker"
11
+ import type { LifelineEventImage, LifelineProps } from "./types"
12
+ import { getLifelineEventImage } from "./lifeline-event"
13
+ import { LifelineHoverImageProvider } from "./lifeline-hover-image"
14
+ import { LifelineFloatingPhotos } from "./lifeline-photos"
15
+ import { useLifelineIntro } from "./use-lifeline-intro"
16
+ import { useLifelineScroll } from "./use-lifeline-scroll"
17
+ import { getMarkerWidth } from "./lifeline-utils"
18
+
19
+ export function LifelineDesktop({
20
+ markers,
21
+ birthYear,
22
+ className,
23
+ title = "Lifeline",
24
+ mode = "auto",
25
+ }: LifelineProps) {
26
+ const widths = useMemo(
27
+ () =>
28
+ markers.map((marker, index) =>
29
+ getMarkerWidth(marker, markers[index + 1]?.year),
30
+ ),
31
+ [markers],
32
+ )
33
+
34
+ // Left edge of each marker's slot within the track — anchors for the
35
+ // floating photo cards.
36
+ const offsets = useMemo(() => {
37
+ const result: number[] = []
38
+ let sum = 0
39
+ for (const width of widths) {
40
+ result.push(sum)
41
+ sum += width
42
+ }
43
+ return result
44
+ }, [widths])
45
+
46
+ const hoverImages = useMemo(() => {
47
+ const images: LifelineEventImage[] = []
48
+ for (const marker of markers) {
49
+ for (const event of marker.events) {
50
+ const image = getLifelineEventImage(event)
51
+ if (image) images.push(image)
52
+ }
53
+ }
54
+ return images
55
+ }, [markers])
56
+
57
+ const intro = useLifelineIntro(widths)
58
+ const isIntroAnimating = intro.shouldPlay && intro.isPlaying
59
+
60
+ const {
61
+ sectionRef,
62
+ trackRef,
63
+ labelsRef,
64
+ setMarkerRef,
65
+ isLayoutReady,
66
+ isEmbed,
67
+ introArmed,
68
+ } = useLifelineScroll(markers.length, {
69
+ mode,
70
+ introLocked: isIntroAnimating,
71
+ introAnimating: isIntroAnimating,
72
+ introSkipped: !intro.shouldPlay,
73
+ introRailMs: intro.railDuration,
74
+ introGetTrackProgress: intro.getTrackProgressAtTime,
75
+ onIntroScrollStart: intro.startIntroTimer,
76
+ onIntroSettleComplete: intro.completeIntro,
77
+ })
78
+
79
+ // Embedded, the open waits for the module to come into view: the marker
80
+ // fades are CSS animations that start the moment their class lands, so
81
+ // applying it early would spend them below the fold.
82
+ const introWaitingInView = isEmbed && intro.shouldPlay && !introArmed
83
+ const showIntro = isIntroAnimating && isLayoutReady && !introWaitingInView
84
+
85
+ const trackWidth =
86
+ LIFELINE_STICKY_SHIELD_WIDTH + widths.reduce((sum, width) => sum + width, 0)
87
+
88
+ const introStyle = {
89
+ "--lifeline-labels-ms": `${intro.labelsDuration}ms`,
90
+ "--lifeline-rail-ms": `${intro.railDuration}ms`,
91
+ } as CSSProperties
92
+
93
+ return (
94
+ <section
95
+ ref={sectionRef}
96
+ data-lifeline-mode={isEmbed ? "embed" : "page"}
97
+ // Embedded, the module needs a tab stop to be operable at all — a
98
+ // page-mode lifeline is reached just by scrolling to it.
99
+ tabIndex={isEmbed ? 0 : undefined}
100
+ className={cn(
101
+ "relative h-full min-h-0 select-none overflow-hidden [&_a]:cursor-pointer",
102
+ // `pan-y` lets the browser start a vertical page scroll on the
103
+ // first frame instead of waiting on the JS axis lock; horizontal
104
+ // panning stays ours.
105
+ isEmbed &&
106
+ cn("touch-pan-y", focusRingBorder, focusRing),
107
+ // Hold it blank rather than showing a settled timeline that then
108
+ // resets itself to play the intro. Below the fold there is nothing
109
+ // to see anyway, and the arming margin means it fills in before it
110
+ // reaches the reader.
111
+ (!isLayoutReady || introWaitingInView) && "invisible",
112
+ className,
113
+ )}
114
+ aria-label={title}
115
+ style={showIntro ? introStyle : undefined}
116
+ >
117
+ <LifelineHoverImageProvider preload={hoverImages}>
118
+ {/*
119
+ Centered — but `safe center` where the browser understands it, which
120
+ matters once the height is the consumer's to choose. A track taller
121
+ than its box would otherwise overflow equally top and bottom, and
122
+ since the section clips, the first thing lost is the row nearest the
123
+ top: the Age/Years label column and the year labels. `safe` falls
124
+ back to start-alignment exactly in that case, so the labels and the
125
+ rail stay put and only the tail of a long column clips. Declared
126
+ inline so browsers without it simply keep the `items-center` class.
127
+ */}
128
+ <div
129
+ className="flex h-full items-center overflow-hidden"
130
+ style={isEmbed ? { alignItems: "safe center" } : undefined}
131
+ >
132
+ <div
133
+ ref={trackRef}
134
+ className="relative flex w-max items-start will-change-transform [--lifeline-people-top:calc(14.5rem+40px)] [--lifeline-rail:5rem]"
135
+ style={{ width: trackWidth }}
136
+ >
137
+ {/*
138
+ LIFELINE_STICKY_SHIELD_WIDTH reserves this column at the head of
139
+ the track, and the column has to actually paint it: once the
140
+ track scrolls, marker text passes underneath and would otherwise
141
+ read straight through "Age" and "Years".
142
+
143
+ `bg-background-primary` to match the framing the shell puts
144
+ around this — reframe the page on a different surface and this
145
+ wants overriding with it. The transition is not decoration
146
+ either: without it the shield snaps between the two while the
147
+ page behind it is still crossfading, which flashes a hard box
148
+ for the length of a theme switch. 300ms on the default curve is
149
+ what `LifelineShell` fades on, so the two move as one.
150
+ */}
151
+ <div
152
+ ref={labelsRef}
153
+ className="lifeline-labels shrink-0 bg-background-primary transition-colors duration-300 will-change-transform"
154
+ style={{ width: LIFELINE_STICKY_SHIELD_WIDTH }}
155
+ >
156
+ <div className={cn(showIntro && "lifeline-labels-intro")}>
157
+ <LifelineStickyLabels />
158
+ </div>
159
+ </div>
160
+
161
+ <div className="relative">
162
+ <div
163
+ aria-hidden="true"
164
+ className="pointer-events-none absolute inset-x-0 top-[var(--lifeline-rail)] h-px overflow-hidden"
165
+ >
166
+ <div
167
+ className={cn(
168
+ "h-px w-full border-t border-dashed border-border-primary transition-colors duration-300",
169
+ showIntro && "lifeline-rail-intro",
170
+ )}
171
+ />
172
+ </div>
173
+
174
+ <div className="relative flex items-start">
175
+ {markers.map((marker, index) => (
176
+ <LifelineMarkerColumn
177
+ key={marker.id}
178
+ ref={(node) => setMarkerRef(index, node)}
179
+ marker={marker}
180
+ birthYear={birthYear}
181
+ minWidth={widths[index]}
182
+ animateIntro={showIntro}
183
+ introDelay={intro.getMarkerDelay(index)}
184
+ introDuration={intro.getMarkerFadeDuration(index)}
185
+ />
186
+ ))}
187
+ </div>
188
+
189
+ <LifelineFloatingPhotos
190
+ markers={markers}
191
+ offsets={offsets}
192
+ widths={widths}
193
+ animateIntro={showIntro}
194
+ getIntroDelay={intro.getMarkerDelay}
195
+ getIntroDuration={intro.getMarkerFadeDuration}
196
+ />
197
+ </div>
198
+ </div>
199
+ </div>
200
+
201
+ </LifelineHoverImageProvider>
202
+ </section>
203
+ )
204
+ }
@@ -0,0 +1,108 @@
1
+ import type {
2
+ LifelineEvent,
3
+ LifelineEventEffect,
4
+ LifelineEventImage,
5
+ LifelineEventSegment,
6
+ } from "./types"
7
+
8
+ function getEventContent(
9
+ event: LifelineEvent,
10
+ ): string | LifelineEventSegment[] {
11
+ if (typeof event === "object" && !Array.isArray(event) && "text" in event) {
12
+ return event.text
13
+ }
14
+
15
+ return event
16
+ }
17
+
18
+ export function getLifelineEventImage(
19
+ event: LifelineEvent,
20
+ ): LifelineEventImage | undefined {
21
+ if (typeof event === "object" && !Array.isArray(event) && "image" in event) {
22
+ return event.image
23
+ }
24
+
25
+ return undefined
26
+ }
27
+
28
+ export function getLifelineEventEffect(
29
+ event: LifelineEvent,
30
+ ): LifelineEventEffect | undefined {
31
+ if (typeof event === "object" && !Array.isArray(event) && "effect" in event) {
32
+ return event.effect
33
+ }
34
+
35
+ return undefined
36
+ }
37
+
38
+ export function LifelineEventText({
39
+ event,
40
+ className,
41
+ }: {
42
+ event: LifelineEvent
43
+ className?: string
44
+ }) {
45
+ const content = getEventContent(event)
46
+
47
+ if (typeof content === "string") {
48
+ return <span className={className}>{content}</span>
49
+ }
50
+
51
+ return (
52
+ <span className={className}>
53
+ {content.map((segment, index) =>
54
+ segment.type === "link" ? (
55
+ <a
56
+ key={index}
57
+ href={segment.href}
58
+ target="_blank"
59
+ rel="noopener noreferrer"
60
+ className="text-inherit underline decoration-fg-quaternary underline-offset-2 transition-colors duration-300 group-hover:text-fg-primary group-hover:decoration-fg-tertiary"
61
+ >
62
+ {segment.value}
63
+ </a>
64
+ ) : (
65
+ <span key={index}>{segment.value}</span>
66
+ ),
67
+ )}
68
+ </span>
69
+ )
70
+ }
71
+
72
+ /** Always-visible media embedded in the timeline (image.inline). */
73
+ export function LifelineEventMedia({
74
+ media,
75
+ className,
76
+ }: {
77
+ media: LifelineEventImage
78
+ className?: string
79
+ }) {
80
+ if (media.video) {
81
+ return (
82
+ <video
83
+ src={media.video}
84
+ poster={media.src}
85
+ autoPlay
86
+ muted
87
+ loop
88
+ playsInline
89
+ preload="metadata"
90
+ aria-label={media.alt}
91
+ className={className}
92
+ />
93
+ )
94
+ }
95
+
96
+ return (
97
+ // eslint-disable-next-line @next/next/no-img-element
98
+ <img src={media.src} alt={media.alt} loading="lazy" className={className} />
99
+ )
100
+ }
101
+
102
+ export function getLifelineEventKey(event: LifelineEvent, index: number) {
103
+ const content = getEventContent(event)
104
+
105
+ if (typeof content === "string") return `${index}-${content}`
106
+
107
+ return `${index}-${content.map((segment) => segment.value).join("")}`
108
+ }
@@ -0,0 +1,302 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react"
12
+ import type { LifelineEventEffect } from "./types"
13
+
14
+ /** Tweak these */
15
+ const DURATION_S = 7.5
16
+ const MAX_DPR = 1.5
17
+ /** Wait for the theme cross-fade before the first burst. */
18
+ const NIGHTFALL_MS = 400
19
+
20
+ type Palette = [number[], number[], number[]]
21
+
22
+ const PALETTES: Record<LifelineEventEffect, Palette> = {
23
+ // Old Glory red / white / blue
24
+ fireworks: [
25
+ [0.9, 0.15, 0.25],
26
+ [1.0, 1.0, 1.0],
27
+ [0.25, 0.45, 0.95],
28
+ ],
29
+ // celeste / white / pale celeste
30
+ "fireworks-argentina": [
31
+ [0.45, 0.75, 0.98],
32
+ [1.0, 1.0, 1.0],
33
+ [0.7, 0.87, 1.0],
34
+ ],
35
+ }
36
+
37
+ const VERTEX_SHADER = `
38
+ attribute vec2 a_pos;
39
+ void main() {
40
+ gl_Position = vec4(a_pos, 0.0, 1.0);
41
+ }
42
+ `
43
+
44
+ /**
45
+ * Additive point-glow fireworks in Old Glory red, white, and blue,
46
+ * composited over a night-sky scrim via premultiplied canvas alpha.
47
+ */
48
+ const FRAGMENT_SHADER = `
49
+ precision highp float;
50
+
51
+ uniform vec2 u_res;
52
+ uniform float u_time;
53
+ uniform float u_dur;
54
+ uniform vec3 u_c0;
55
+ uniform vec3 u_c1;
56
+ uniform vec3 u_c2;
57
+
58
+ #define TAU 6.28318530718
59
+ #define N_FIREWORKS 10
60
+ #define N_PARTICLES 42
61
+
62
+ float hash(float n) {
63
+ return fract(sin(n) * 43758.5453123);
64
+ }
65
+
66
+ vec3 palette(float m) {
67
+ if (m < 0.5) return u_c0;
68
+ if (m < 1.5) return u_c1;
69
+ return u_c2;
70
+ }
71
+
72
+ void main() {
73
+ vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
74
+ float t = u_time;
75
+ float env = smoothstep(0.0, 0.5, t) * (1.0 - smoothstep(u_dur - 1.0, u_dur, t));
76
+
77
+ vec3 col = vec3(0.0);
78
+
79
+ for (int i = 0; i < N_FIREWORKS; i++) {
80
+ float fi = float(i);
81
+ float t0 = 0.35 + fi * (u_dur - 2.8) / float(N_FIREWORKS) + hash(fi * 7.31) * 0.3;
82
+ // No flow control in the loop — some WebGL1 driver translations
83
+ // mishandle continue, so inactive bursts multiply to zero instead.
84
+ float active = step(t0, t) * step(t, t0 + 1.8);
85
+ float lt = clamp((t - t0) / 1.8, 0.0, 1.0);
86
+
87
+ vec2 center = vec2((hash(fi * 3.7) - 0.5) * 1.6, -0.05 + hash(fi * 9.1) * 0.5);
88
+ vec3 base = palette(mod(fi, 3.0)); // strict red / white / blue rotation
89
+ // Ramp in fast so overlapping particles at ignition don't stack
90
+ // into a blown-out ball, then decay.
91
+ float fade = exp(-lt * 4.0) * min(1.0, lt * 6.0);
92
+
93
+ for (int j = 0; j < N_PARTICLES; j++) {
94
+ float fj = float(j);
95
+ float angle = (fj / float(N_PARTICLES)) * TAU + hash(fi * 100.0 + fj) * 0.15;
96
+ float speed = 0.16 + 0.22 * hash(fj * 7.77 + fi * 31.3);
97
+
98
+ vec2 p = center + vec2(cos(angle), sin(angle)) * speed * sqrt(lt);
99
+ p.y -= 0.09 * lt * lt; // gravity
100
+
101
+ float d = max(length(uv - p), 0.004);
102
+ float sparkle = 0.7 + 0.3 * sin(30.0 * lt + fj * 1.7);
103
+ col += base * active * fade * sparkle * 0.0006 / (d * d);
104
+ }
105
+ }
106
+
107
+ col = clamp(col * env, 0.0, 1.0);
108
+ float spark = max(col.r, max(col.g, col.b));
109
+ float scrim = 0.45 * env;
110
+ gl_FragColor = vec4(col, max(spark, scrim));
111
+ }
112
+ `
113
+
114
+ interface LifelineFireworksApi {
115
+ launch: (effect: LifelineEventEffect) => void
116
+ }
117
+
118
+ const LifelineFireworksContext = createContext<LifelineFireworksApi | null>(
119
+ null,
120
+ )
121
+
122
+ export function useLifelineFireworks() {
123
+ return useContext(LifelineFireworksContext)
124
+ }
125
+
126
+ function FireworksCanvas({
127
+ palette,
128
+ onDone,
129
+ }: {
130
+ palette: Palette
131
+ onDone: () => void
132
+ }) {
133
+ const paletteRef = useRef(palette)
134
+ paletteRef.current = palette
135
+ const canvasRef = useRef<HTMLCanvasElement>(null)
136
+ const onDoneRef = useRef(onDone)
137
+ onDoneRef.current = onDone
138
+
139
+ useEffect(() => {
140
+ const canvas = canvasRef.current
141
+ if (!canvas) return
142
+
143
+ const gl = canvas.getContext("webgl", {
144
+ alpha: true,
145
+ premultipliedAlpha: true,
146
+ antialias: false,
147
+ })
148
+ if (!gl) {
149
+ onDoneRef.current()
150
+ return
151
+ }
152
+
153
+ const compile = (type: number, source: string) => {
154
+ const shader = gl.createShader(type)!
155
+ gl.shaderSource(shader, source)
156
+ gl.compileShader(shader)
157
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
158
+ console.warn("fireworks shader:", gl.getShaderInfoLog(shader))
159
+ }
160
+ return shader
161
+ }
162
+
163
+ const program = gl.createProgram()!
164
+ gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER))
165
+ gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER))
166
+ gl.linkProgram(program)
167
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
168
+ console.warn("fireworks link:", gl.getProgramInfoLog(program))
169
+ onDoneRef.current()
170
+ return
171
+ }
172
+ gl.useProgram(program)
173
+
174
+ const buffer = gl.createBuffer()
175
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
176
+ gl.bufferData(
177
+ gl.ARRAY_BUFFER,
178
+ new Float32Array([-1, -1, 3, -1, -1, 3]),
179
+ gl.STATIC_DRAW,
180
+ )
181
+ const aPos = gl.getAttribLocation(program, "a_pos")
182
+ gl.enableVertexAttribArray(aPos)
183
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
184
+
185
+ const uRes = gl.getUniformLocation(program, "u_res")
186
+ const uTime = gl.getUniformLocation(program, "u_time")
187
+ const uDur = gl.getUniformLocation(program, "u_dur")
188
+
189
+ const [c0, c1, c2] = paletteRef.current
190
+ gl.uniform3f(gl.getUniformLocation(program, "u_c0"), c0[0], c0[1], c0[2])
191
+ gl.uniform3f(gl.getUniformLocation(program, "u_c1"), c1[0], c1[1], c1[2])
192
+ gl.uniform3f(gl.getUniformLocation(program, "u_c2"), c2[0], c2[1], c2[2])
193
+
194
+ const resize = () => {
195
+ const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
196
+ canvas.width = Math.round(window.innerWidth * dpr)
197
+ canvas.height = Math.round(window.innerHeight * dpr)
198
+ gl.viewport(0, 0, canvas.width, canvas.height)
199
+ }
200
+ resize()
201
+ window.addEventListener("resize", resize)
202
+
203
+ let frame = 0
204
+ const start = performance.now()
205
+
206
+ const step = (now: number) => {
207
+ const t = (now - start) / 1000
208
+ if (t >= DURATION_S) {
209
+ onDoneRef.current()
210
+ return
211
+ }
212
+
213
+ gl.uniform2f(uRes, canvas.width, canvas.height)
214
+ gl.uniform1f(uTime, t)
215
+ gl.uniform1f(uDur, DURATION_S)
216
+ gl.clearColor(0, 0, 0, 0)
217
+ gl.clear(gl.COLOR_BUFFER_BIT)
218
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
219
+
220
+ frame = requestAnimationFrame(step)
221
+ }
222
+ frame = requestAnimationFrame(step)
223
+
224
+ return () => {
225
+ cancelAnimationFrame(frame)
226
+ window.removeEventListener("resize", resize)
227
+ // No manual loseContext: StrictMode re-runs this effect on the
228
+ // same canvas, and a lost context can never compile again.
229
+ }
230
+ }, [])
231
+
232
+ return (
233
+ <canvas
234
+ ref={canvasRef}
235
+ aria-hidden="true"
236
+ className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
237
+ />
238
+ )
239
+ }
240
+
241
+ function isDarkDocument() {
242
+ return document.documentElement.classList.contains("dark")
243
+ }
244
+
245
+ function setDocumentDark(dark: boolean) {
246
+ document.documentElement.classList.toggle("dark", dark)
247
+ }
248
+
249
+ export function LifelineFireworksProvider({
250
+ children,
251
+ }: {
252
+ children: ReactNode
253
+ }) {
254
+ const [playing, setPlaying] = useState(false)
255
+ const [effect, setEffect] = useState<LifelineEventEffect>("fireworks")
256
+ const restoreThemeRef = useRef<"light" | null>(null)
257
+ const nightfallRef = useRef(0)
258
+ const playingRef = useRef(false)
259
+ playingRef.current = playing
260
+
261
+ useEffect(() => {
262
+ return () => window.clearTimeout(nightfallRef.current)
263
+ }, [])
264
+
265
+ const launch = useCallback((nextEffect: LifelineEventEffect) => {
266
+ if (playingRef.current) return
267
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
268
+
269
+ setEffect(nextEffect)
270
+
271
+ // Fireworks belong in the dark: switch a light page to dark for
272
+ // the show, and restore afterwards.
273
+ if (!isDarkDocument()) {
274
+ restoreThemeRef.current = "light"
275
+ setDocumentDark(true)
276
+ window.clearTimeout(nightfallRef.current)
277
+ nightfallRef.current = window.setTimeout(
278
+ () => setPlaying(true),
279
+ NIGHTFALL_MS,
280
+ )
281
+ return
282
+ }
283
+
284
+ restoreThemeRef.current = null
285
+ setPlaying(true)
286
+ }, [])
287
+
288
+ const done = useCallback(() => {
289
+ setPlaying(false)
290
+ if (restoreThemeRef.current) {
291
+ setDocumentDark(false)
292
+ restoreThemeRef.current = null
293
+ }
294
+ }, [])
295
+
296
+ return (
297
+ <LifelineFireworksContext.Provider value={{ launch }}>
298
+ {children}
299
+ {playing && <FireworksCanvas palette={PALETTES[effect]} onDone={done} />}
300
+ </LifelineFireworksContext.Provider>
301
+ )
302
+ }