lanre-ds 0.0.0-stage → 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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/components/buttons/Button.d.ts +28 -0
  3. package/components/buttons/Button.jsx +50 -0
  4. package/components/buttons/Button.prompt.md +16 -0
  5. package/components/buttons/IconButton.d.ts +23 -0
  6. package/components/buttons/IconButton.jsx +53 -0
  7. package/components/buttons/IconButton.prompt.md +12 -0
  8. package/components/buttons/SegmentedTabs.d.ts +21 -0
  9. package/components/buttons/SegmentedTabs.jsx +15 -0
  10. package/components/buttons/SegmentedTabs.prompt.md +10 -0
  11. package/components/contributor/AudioRecorder.d.ts +27 -0
  12. package/components/contributor/AudioRecorder.jsx +79 -0
  13. package/components/contributor/AudioRecorder.prompt.md +13 -0
  14. package/components/contributor/RadioCard.d.ts +17 -0
  15. package/components/contributor/RadioCard.jsx +30 -0
  16. package/components/contributor/RadioCard.prompt.md +12 -0
  17. package/components/contributor/RecorderPanel.d.ts +30 -0
  18. package/components/contributor/RecorderPanel.jsx +49 -0
  19. package/components/contributor/RecorderPanel.prompt.md +11 -0
  20. package/components/contributor/StepBar.d.ts +17 -0
  21. package/components/contributor/StepBar.jsx +55 -0
  22. package/components/contributor/StepBar.prompt.md +12 -0
  23. package/components/contributor/TextField.d.ts +25 -0
  24. package/components/contributor/TextField.jsx +29 -0
  25. package/components/contributor/TextField.prompt.md +7 -0
  26. package/components/contributor/UploadTile.d.ts +39 -0
  27. package/components/contributor/UploadTile.jsx +116 -0
  28. package/components/contributor/UploadTile.prompt.md +21 -0
  29. package/components/contributor/WorldChip.d.ts +18 -0
  30. package/components/contributor/WorldChip.jsx +16 -0
  31. package/components/contributor/WorldChip.prompt.md +9 -0
  32. package/components/contributor/WorldOption.d.ts +19 -0
  33. package/components/contributor/WorldOption.jsx +19 -0
  34. package/components/contributor/WorldOption.prompt.md +11 -0
  35. package/components/contributor/WorldSign.d.ts +25 -0
  36. package/components/contributor/WorldSign.jsx +32 -0
  37. package/components/contributor/WorldSign.prompt.md +14 -0
  38. package/components/contributor/WorldSigns.d.ts +13 -0
  39. package/components/contributor/WorldSigns.jsx +12 -0
  40. package/components/contributor/WorldSigns.prompt.md +8 -0
  41. package/components/effects/Confetti.d.ts +14 -0
  42. package/components/effects/Confetti.jsx +38 -0
  43. package/components/effects/Confetti.prompt.md +12 -0
  44. package/components/effects/Sparkles.d.ts +9 -0
  45. package/components/effects/Sparkles.jsx +13 -0
  46. package/components/effects/Sparkles.prompt.md +11 -0
  47. package/components/effects/Sunburst.d.ts +23 -0
  48. package/components/effects/Sunburst.jsx +26 -0
  49. package/components/effects/Sunburst.prompt.md +12 -0
  50. package/components/feedback/Badge.d.ts +17 -0
  51. package/components/feedback/Badge.jsx +16 -0
  52. package/components/feedback/Badge.prompt.md +10 -0
  53. package/components/feedback/ErrorText.d.ts +11 -0
  54. package/components/feedback/ErrorText.jsx +8 -0
  55. package/components/feedback/ErrorText.prompt.md +9 -0
  56. package/components/feedback/LoadingScreen.d.ts +14 -0
  57. package/components/feedback/LoadingScreen.jsx +24 -0
  58. package/components/feedback/LoadingScreen.prompt.md +9 -0
  59. package/components/feedback/Notice.d.ts +15 -0
  60. package/components/feedback/Notice.jsx +16 -0
  61. package/components/feedback/Notice.prompt.md +10 -0
  62. package/components/feedback/SaveAlert.d.ts +15 -0
  63. package/components/feedback/SaveAlert.jsx +24 -0
  64. package/components/feedback/SaveAlert.prompt.md +9 -0
  65. package/components/feedback/SavePill.d.ts +13 -0
  66. package/components/feedback/SavePill.jsx +29 -0
  67. package/components/feedback/SavePill.prompt.md +8 -0
  68. package/components/feedback/SoundToggle.d.ts +16 -0
  69. package/components/feedback/SoundToggle.jsx +15 -0
  70. package/components/feedback/SoundToggle.prompt.md +6 -0
  71. package/components/feedback/Sticker.d.ts +15 -0
  72. package/components/feedback/Sticker.jsx +12 -0
  73. package/components/feedback/Sticker.prompt.md +9 -0
  74. package/components/feedback/Tag.d.ts +15 -0
  75. package/components/feedback/Tag.jsx +15 -0
  76. package/components/feedback/Tag.prompt.md +10 -0
  77. package/components/feedback/Toast.d.ts +21 -0
  78. package/components/feedback/Toast.jsx +19 -0
  79. package/components/feedback/Toast.prompt.md +7 -0
  80. package/components/forms/Slider.d.ts +24 -0
  81. package/components/forms/Slider.jsx +28 -0
  82. package/components/forms/Slider.prompt.md +6 -0
  83. package/components/forms/Switch.d.ts +19 -0
  84. package/components/forms/Switch.jsx +20 -0
  85. package/components/forms/Switch.prompt.md +6 -0
  86. package/components/games/MatchCard.d.ts +18 -0
  87. package/components/games/MatchCard.jsx +27 -0
  88. package/components/games/MatchCard.prompt.md +11 -0
  89. package/components/hud/Countdown.d.ts +33 -0
  90. package/components/hud/Countdown.jsx +74 -0
  91. package/components/hud/Countdown.prompt.md +15 -0
  92. package/components/hud/CurrencyPill.d.ts +24 -0
  93. package/components/hud/CurrencyPill.jsx +34 -0
  94. package/components/hud/CurrencyPill.prompt.md +12 -0
  95. package/components/hud/HudBar.d.ts +18 -0
  96. package/components/hud/HudBar.jsx +14 -0
  97. package/components/hud/HudBar.prompt.md +15 -0
  98. package/components/hud/HudChip.d.ts +25 -0
  99. package/components/hud/HudChip.jsx +30 -0
  100. package/components/hud/HudChip.prompt.md +13 -0
  101. package/components/hud/ProgressBar.d.ts +27 -0
  102. package/components/hud/ProgressBar.jsx +39 -0
  103. package/components/hud/ProgressBar.prompt.md +11 -0
  104. package/components/hud/TimerChip.d.ts +19 -0
  105. package/components/hud/TimerChip.jsx +17 -0
  106. package/components/hud/TimerChip.prompt.md +7 -0
  107. package/components/icons/GameIcon.d.ts +25 -0
  108. package/components/icons/GameIcon.jsx +43 -0
  109. package/components/icons/GameIcon.prompt.md +13 -0
  110. package/components/identity/Avatar.d.ts +26 -0
  111. package/components/identity/Avatar.jsx +31 -0
  112. package/components/identity/Avatar.prompt.md +11 -0
  113. package/components/identity/AvatarButton.d.ts +19 -0
  114. package/components/identity/AvatarButton.jsx +13 -0
  115. package/components/identity/AvatarButton.prompt.md +8 -0
  116. package/components/identity/Gemstone.d.ts +28 -0
  117. package/components/identity/Gemstone.jsx +46 -0
  118. package/components/identity/Gemstone.prompt.md +15 -0
  119. package/components/identity/PersonTile.d.ts +22 -0
  120. package/components/identity/PersonTile.jsx +27 -0
  121. package/components/identity/PersonTile.prompt.md +10 -0
  122. package/components/levels/ChoiceButton.d.ts +20 -0
  123. package/components/levels/ChoiceButton.jsx +31 -0
  124. package/components/levels/ChoiceButton.prompt.md +10 -0
  125. package/components/map/DiscoveryMarker.d.ts +19 -0
  126. package/components/map/DiscoveryMarker.jsx +31 -0
  127. package/components/map/DiscoveryMarker.prompt.md +11 -0
  128. package/components/map/LevelNode.d.ts +19 -0
  129. package/components/map/LevelNode.jsx +37 -0
  130. package/components/map/LevelNode.prompt.md +11 -0
  131. package/components/map/PortraitMedallion.d.ts +27 -0
  132. package/components/map/PortraitMedallion.jsx +29 -0
  133. package/components/map/PortraitMedallion.prompt.md +14 -0
  134. package/components/map/StarRating.d.ts +22 -0
  135. package/components/map/StarRating.jsx +21 -0
  136. package/components/map/StarRating.prompt.md +6 -0
  137. package/components/map/Tile.d.ts +22 -0
  138. package/components/map/Tile.jsx +32 -0
  139. package/components/map/Tile.prompt.md +8 -0
  140. package/components/map/WorldGate.d.ts +21 -0
  141. package/components/map/WorldGate.jsx +27 -0
  142. package/components/map/WorldGate.prompt.md +13 -0
  143. package/components/map/worlds.d.ts +18 -0
  144. package/components/map/worlds.js +9 -0
  145. package/components/media/Album.d.ts +33 -0
  146. package/components/media/Album.jsx +157 -0
  147. package/components/media/Album.prompt.md +16 -0
  148. package/components/media/Cassette.d.ts +23 -0
  149. package/components/media/Cassette.jsx +64 -0
  150. package/components/media/Cassette.prompt.md +13 -0
  151. package/components/media/Film.d.ts +20 -0
  152. package/components/media/Film.jsx +39 -0
  153. package/components/media/Film.prompt.md +14 -0
  154. package/components/media/MemoryText.d.ts +20 -0
  155. package/components/media/MemoryText.jsx +14 -0
  156. package/components/media/MemoryText.prompt.md +9 -0
  157. package/components/media/Unavailable.d.ts +13 -0
  158. package/components/media/Unavailable.jsx +13 -0
  159. package/components/media/Unavailable.prompt.md +10 -0
  160. package/components/media/Words.d.ts +21 -0
  161. package/components/media/Words.jsx +57 -0
  162. package/components/media/Words.prompt.md +12 -0
  163. package/components/media/shared.js +19 -0
  164. package/components/panels/Modal.d.ts +25 -0
  165. package/components/panels/Modal.jsx +68 -0
  166. package/components/panels/Modal.prompt.md +20 -0
  167. package/components/panels/Panel.d.ts +29 -0
  168. package/components/panels/Panel.jsx +109 -0
  169. package/components/panels/Panel.prompt.md +13 -0
  170. package/components/quiz/LeaderboardRow.d.ts +26 -0
  171. package/components/quiz/LeaderboardRow.jsx +24 -0
  172. package/components/quiz/LeaderboardRow.prompt.md +10 -0
  173. package/components/rewards/ItemCell.d.ts +24 -0
  174. package/components/rewards/ItemCell.jsx +24 -0
  175. package/components/rewards/ItemCell.prompt.md +9 -0
  176. package/components/rewards/RewardCard.d.ts +24 -0
  177. package/components/rewards/RewardCard.jsx +34 -0
  178. package/components/rewards/RewardCard.prompt.md +8 -0
  179. package/components/rewards/ShopRow.d.ts +23 -0
  180. package/components/rewards/ShopRow.jsx +26 -0
  181. package/components/rewards/ShopRow.prompt.md +7 -0
  182. package/components/text/Heading.d.ts +19 -0
  183. package/components/text/Heading.jsx +15 -0
  184. package/components/text/Heading.prompt.md +10 -0
  185. package/components/text/OutlineText.d.ts +24 -0
  186. package/components/text/OutlineText.jsx +20 -0
  187. package/components/text/OutlineText.prompt.md +11 -0
  188. package/components/toolbar/FastTravelDock.d.ts +50 -0
  189. package/components/toolbar/FastTravelDock.jsx +36 -0
  190. package/components/toolbar/FastTravelDock.prompt.md +12 -0
  191. package/components/toolbar/Toolbar.d.ts +16 -0
  192. package/components/toolbar/Toolbar.jsx +8 -0
  193. package/components/toolbar/Toolbar.prompt.md +14 -0
  194. package/components/toolbar/ToolbarNext.d.ts +21 -0
  195. package/components/toolbar/ToolbarNext.jsx +21 -0
  196. package/components/toolbar/ToolbarNext.prompt.md +13 -0
  197. package/components/toolbar/ToolbarTab.d.ts +15 -0
  198. package/components/toolbar/ToolbarTab.jsx +12 -0
  199. package/components/toolbar/ToolbarTab.prompt.md +9 -0
  200. package/components/viewer/ClueCard.d.ts +18 -0
  201. package/components/viewer/ClueCard.jsx +14 -0
  202. package/components/viewer/ClueCard.prompt.md +9 -0
  203. package/components/viewer/ClueTab.d.ts +16 -0
  204. package/components/viewer/ClueTab.jsx +12 -0
  205. package/components/viewer/ClueTab.prompt.md +8 -0
  206. package/components/viewer/RevealTray.d.ts +54 -0
  207. package/components/viewer/RevealTray.jsx +58 -0
  208. package/components/viewer/RevealTray.prompt.md +21 -0
  209. package/components/viewer/RoadsideViewer.d.ts +60 -0
  210. package/components/viewer/RoadsideViewer.jsx +96 -0
  211. package/components/viewer/RoadsideViewer.prompt.md +20 -0
  212. package/components/viewer/ViewerKeys.d.ts +26 -0
  213. package/components/viewer/ViewerKeys.jsx +22 -0
  214. package/components/viewer/ViewerKeys.prompt.md +14 -0
  215. package/components/viewer/ViewerKnob.d.ts +20 -0
  216. package/components/viewer/ViewerKnob.jsx +12 -0
  217. package/components/viewer/ViewerKnob.prompt.md +11 -0
  218. package/components/viewer/WhoButton.d.ts +21 -0
  219. package/components/viewer/WhoButton.jsx +15 -0
  220. package/components/viewer/WhoButton.prompt.md +10 -0
  221. package/package.json +62 -4
  222. package/readme.md +145 -0
  223. package/styles.css +15 -0
  224. package/tokens/colors.css +140 -0
  225. package/tokens/effects.css +40 -0
  226. package/tokens/fonts.css +3 -0
  227. package/tokens/fx.css +186 -0
  228. package/tokens/gems.css +8 -0
  229. package/tokens/hud.css +510 -0
  230. package/tokens/icons.css +7 -0
  231. package/tokens/media.css +774 -0
  232. package/tokens/signs.css +459 -0
  233. package/tokens/spacing.css +38 -0
  234. package/tokens/typography.css +34 -0
  235. package/tokens/ui.css +432 -0
  236. package/tokens/utilities.css +34 -0
  237. package/tokens/viewer.css +1176 -0
  238. package/tokens/worlds.css +73 -0
  239. package/tools/ds-loader.js +60 -0
  240. package/README.md +0 -3
@@ -0,0 +1,12 @@
1
+ Step bar: the top of every contributor screen, in the HUD between the back button and the right-hand slot. It reads like the map, with step markers on a short stretch of road that fills green as steps finish.
2
+
3
+ ```jsx
4
+ <StepBar steps={['You', 'Memories', 'Preview', 'Submit']} current={1} />
5
+ <StepBar steps={['Where', 'Who', 'Write', 'Details']} current={2} labels />
6
+ ```
7
+
8
+ - Use the plain bar for the main road, and `labels` for the steps inside one memory.
9
+ - Without `labels`, each step is still read out, as "Step 2 of 4: Memories".
10
+ - A step forward celebrates. The green grows to the new marker and the finished one pops. It only happens when the bar mounts one step further on than it was last seen on that page load, so going back or reloading stays calm.
11
+ - The markers aren't buttons. Going back is the back button's job.
12
+ - It takes its road colours from the world, so put it inside a `data-world`.
@@ -0,0 +1,25 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Labelled text input / textarea in the parchment style: 52px tall, inset well, blue focus, coral error with a warning glyph, optional counter.
5
+ * @startingPoint section="Contributor" subtitle="Input + textarea with helper, error, counter" viewport="700x260"
6
+ */
7
+ export interface TextFieldProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style' | 'onChange'> {
8
+ label?: React.ReactNode;
9
+ value?: string;
10
+ onChange?: (value: string) => void;
11
+ placeholder?: string;
12
+ helper?: React.ReactNode;
13
+ /** Error message; sets aria-invalid. */
14
+ error?: React.ReactNode;
15
+ multiline?: boolean;
16
+ rows?: number;
17
+ maxLength?: number;
18
+ /** Appends "· optional" to the label. */
19
+ optional?: boolean;
20
+ id?: string;
21
+ style?: React.CSSProperties;
22
+ inputStyle?: React.CSSProperties;
23
+ }
24
+
25
+ export function TextField(props: TextFieldProps): React.JSX.Element;
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import React, { useId, useState } from 'react';
3
+
4
+ export function TextField({ label, value = '', onChange, placeholder, helper, error, multiline = false, rows = 4, maxLength, optional = false, id, style, inputStyle, ...rest }) {
5
+ const auto = 'mp-field-' + useId().replace(/:/g, '');
6
+ const fid = id || auto;
7
+ const [focus, setFocus] = useState(false);
8
+ const border = error ? 'var(--coral-500)' : focus ? 'var(--blue-300)' : 'var(--cream-300)';
9
+ const Tag = multiline ? 'textarea' : 'input';
10
+ return (
11
+ <div style={{ display: 'grid', gap: 6, fontFamily: 'var(--font-ui)', ...style }} {...rest}>
12
+ {label && (
13
+ <label htmlFor={fid} style={{ fontWeight: 700, fontSize: 14, color: 'var(--ink-700)', display: 'flex', justifyContent: 'space-between', gap: 8 }}>
14
+ <span>{label}{optional && <span style={{ fontWeight: 600, color: 'var(--ink-500)' }}> · optional</span>}</span>
15
+ {maxLength != null && <span style={{ fontWeight: 600, fontSize: 12, color: String(value).length > maxLength ? 'var(--coral-700)' : 'var(--ink-500)', fontVariantNumeric: 'tabular-nums' }}>{String(value).length + '/' + maxLength}</span>}
16
+ </label>
17
+ )}
18
+ <Tag id={fid} value={value} onChange={e => onChange && onChange(e.target.value)} placeholder={placeholder} rows={multiline ? rows : undefined} maxLength={maxLength} aria-invalid={!!error || undefined} aria-describedby={helper || error ? fid + '-help' : undefined}
19
+ onFocus={() => setFocus(true)} onBlur={() => setFocus(false)}
20
+ style={{ width: '100%', boxSizing: 'border-box', minHeight: 52, padding: multiline ? '14px 16px' : '0 16px', borderRadius: 'var(--radius-md)', border: '3px solid ' + border, background: 'var(--cream-50)', boxShadow: 'inset 0 2px 4px oklch(0 0 0 / 0.06)',
21
+ fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: 16, lineHeight: 1.4, color: 'var(--ink-900)', outline: 'none', resize: multiline ? 'vertical' : 'none', transition: 'border-color var(--dur-fast)', ...inputStyle }} />
22
+ {(error || helper) && (
23
+ <div id={fid + '-help'} style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 600, fontSize: 13, color: error ? 'var(--coral-700)' : 'var(--ink-500)' }}>
24
+ {error && <i className="ph-fill ph-warning" aria-hidden="true" />}{error || helper}
25
+ </div>
26
+ )}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,7 @@
1
+ Text field: contributor onboarding and the memory composer. Always a visible label; helper text explains what the recipient will see.
2
+
3
+ ```jsx
4
+ <TextField label="The name the recipient knows you by" value={name} onChange={setName} helper="Shown only after the reveal." />
5
+ <TextField label="Your memory" multiline rows={6} value={text} onChange={setText} maxLength={2000} />
6
+ <TextField label="Relationship" value={rel} onChange={setRel} error="Please add how you know them." />
7
+ ```
@@ -0,0 +1,39 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * One block of a memory: a numbered step marker, its kind, and keys to move or remove it. Photo, video and voice note blocks add a thumbnail and the upload's state. The caption or the text field goes in as children.
5
+ * @startingPoint section="Contributor" subtitle="Memory blocks: text, and every upload state" viewport="700x620"
6
+ */
7
+ export interface UploadTileProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style'> {
8
+ type?: 'text' | 'photo' | 'video' | 'audio';
9
+ /** 0-based place in the memory. Every block after the first gets a stretch of road up to the one above. */
10
+ index?: number;
11
+ /** How many blocks the memory has, so the last one can't move down. */
12
+ count?: number;
13
+ /** Upload state for photo, video and audio blocks. stalled = it stopped before finishing; missing = the file is gone. */
14
+ state?: 'uploading' | 'done' | 'error' | 'stalled' | 'missing';
15
+ /** 0–1 while uploading. */
16
+ progress?: number;
17
+ /** Filename, for the contributor only. */
18
+ name?: string;
19
+ /** Duration and size, e.g. "0:42 · 12.6 MB". */
20
+ details?: React.ReactNode;
21
+ /** Thumbnail URL for photos. */
22
+ thumb?: string;
23
+ /** Why the upload failed. Shown after "Upload failed". */
24
+ reason?: string;
25
+ /** A message under the upload's state, such as a retry that failed too. */
26
+ error?: React.ReactNode;
27
+ onMoveUp?: () => void;
28
+ onMoveDown?: () => void;
29
+ onRemove?: () => void;
30
+ /** Shows Retry on failed and stalled uploads. */
31
+ onRetry?: () => void;
32
+ /** Shows Cancel while uploading. */
33
+ onCancel?: () => void;
34
+ /** The caption field, or the text field of a text block. */
35
+ children?: React.ReactNode;
36
+ style?: React.CSSProperties;
37
+ }
38
+
39
+ export function UploadTile(props: UploadTileProps): React.JSX.Element;
@@ -0,0 +1,116 @@
1
+ 'use client';
2
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { Button } from '../buttons/Button';
4
+
5
+ const useBrowserEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
6
+
7
+ const TYPE = { text: ['article', 'Text'], photo: ['image', 'Photo'], video: ['video-camera', 'Video'], audio: ['microphone', 'Voice note'] };
8
+ const DROP = '0 12px 20px oklch(0.22 0.05 60 / 0.25)';
9
+ const WARN = { color: 'var(--coral-700)' };
10
+ const LINE = { display: 'inline-flex', alignItems: 'center', gap: 4 };
11
+ const FONT = { fontFamily: 'inherit', fontStyle: 'inherit', lineHeight: 'inherit' };
12
+
13
+ // Square cream key for moving and removing a block. One that can't act is aria-disabled rather than disabled,
14
+ // so it keeps focus when its block reaches the top or the bottom.
15
+ function Key({ label, disabled, onClick, buttonRef, children }) {
16
+ const [down, setDown] = useState(false);
17
+ const pressed = down && !disabled;
18
+ return (
19
+ <button type="button" ref={buttonRef} aria-label={label} aria-disabled={disabled || undefined} onClick={disabled ? undefined : onClick}
20
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerCancel={() => setDown(false)} onPointerLeave={() => setDown(false)}
21
+ style={{ appearance: 'none', boxSizing: 'border-box', border: '2px solid var(--cream-300)', background: 'linear-gradient(180deg, #fff, var(--cream-100))',
22
+ boxShadow: pressed ? '0 1px 0 var(--cream-300)' : '0 3px 0 var(--cream-300), inset 0 2px 0 oklch(1 0 0 / 0.45)', width: 44, height: 44, borderRadius: 12,
23
+ display: 'grid', placeItems: 'center', ...FONT, fontWeight: 'inherit', fontSize: 18, color: 'var(--ink-700)', cursor: disabled ? 'default' : 'pointer', flex: 'none',
24
+ opacity: disabled ? 0.4 : undefined, transform: pressed ? 'translateY(2px)' : undefined,
25
+ transition: 'transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out)' }}>
26
+ {children}
27
+ </button>
28
+ );
29
+ }
30
+
31
+ function Status({ state, pct, reason }) {
32
+ if (state === 'missing') return <span style={WARN}>File missing. Remove this and add it again.</span>;
33
+ if (state === 'done') return <span style={{ color: 'var(--green-700)', ...LINE, animation: 'mp-pop var(--dur-base) var(--ease-bounce) both' }}><i className="ph-fill ph-check-circle" aria-hidden="true" /> Uploaded</span>;
34
+ if (state === 'error') return <span style={{ ...WARN, ...LINE }}><i className="ph-fill ph-warning-circle" aria-hidden="true" /> Upload failed{reason ? ': ' + reason : ''}</span>;
35
+ // The percentage is hidden from the live region, so it announces "Uploading" once, not every tick. The progress bar carries the number.
36
+ if (state === 'uploading') return <span>Uploading <span aria-hidden="true">{pct}%</span></span>;
37
+ return <span style={WARN}>Upload didn&apos;t finish</span>;
38
+ }
39
+
40
+ export function UploadTile({ type = 'photo', index = 0, count, state = 'done', progress = 0, name, details, thumb, reason, error,
41
+ onMoveUp, onMoveDown, onRemove, onRetry, onCancel, children, style, ...rest }) {
42
+ const [glyph, kind] = TYPE[type] || TYPE.photo;
43
+ const media = type !== 'text';
44
+ const failed = media && (state === 'error' || state === 'missing');
45
+ const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100);
46
+ const label = kind + ' ' + (index + 1);
47
+ // Moving a block can move it in the page, which drops focus. So when a move key had focus, it gets it back
48
+ // once the block lands somewhere new.
49
+ const upKey = useRef(null), downKey = useRef(null), moved = useRef(null);
50
+ const move = (key, handler) => e => {
51
+ moved.current = key.current && key.current === document.activeElement ? { key, from: index } : null;
52
+ handler(e);
53
+ // Clear it once the event is over, so a move that never lands can't pull focus back on a later render.
54
+ setTimeout(() => { moved.current = null; });
55
+ };
56
+ useBrowserEffect(() => {
57
+ const m = moved.current;
58
+ if (!m || m.from === index) return;
59
+ moved.current = null;
60
+ if (m.key.current && m.key.current !== document.activeElement) m.key.current.focus();
61
+ });
62
+ return (
63
+ <div style={{ position: 'relative', ...style }} {...rest}>
64
+ {/* From the second block on, a short stretch of road joins it to the one above. Stack blocks 22px apart. */}
65
+ {index > 0 && <span aria-hidden="true" style={{ position: 'absolute', left: 28, top: -26, width: 12, height: 30, borderRadius: 999, background: 'var(--world-path)', boxShadow: '0 0 0 3px var(--world-path-edge)' }} />}
66
+ <div style={{ position: 'relative', boxSizing: 'border-box', background: failed ? 'var(--coral-100)' : 'linear-gradient(180deg, var(--cream-50), var(--cream-100))',
67
+ border: 'var(--border-panel) solid ' + (failed ? 'var(--coral-500)' : 'var(--cream-300)'), borderRadius: 'var(--radius-lg)',
68
+ boxShadow: '0 5px 0 ' + (failed ? 'var(--coral-700)' : 'var(--cream-300)') + ', ' + DROP, padding: 'var(--space-3)', display: 'grid', gap: 'var(--space-3)', minWidth: 0 }}>
69
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontWeight: 700, fontSize: 14, color: 'var(--ink-700)' }}>
70
+ <span className="mp-step mp-step-current mp-step-lg" aria-hidden="true" style={{ minWidth: 0 }}>{index + 1}</span>
71
+ <span style={{ flex: 1, minWidth: 0, display: 'inline-flex', alignItems: 'center', gap: 6 }}>
72
+ <i className={'ph-fill ph-' + glyph} aria-hidden="true" style={{ fontSize: 20 }} />
73
+ {kind}
74
+ </span>
75
+ {onMoveUp && <Key label={'Move ' + label + ' up'} disabled={index === 0} onClick={move(upKey, onMoveUp)} buttonRef={upKey}><i className="ph-bold ph-arrow-up" aria-hidden="true" /></Key>}
76
+ {onMoveDown && <Key label={'Move ' + label + ' down'} disabled={count !== undefined && index >= count - 1} onClick={move(downKey, onMoveDown)} buttonRef={downKey}><i className="ph-bold ph-arrow-down" aria-hidden="true" /></Key>}
77
+ {onRemove && <Key label={'Remove ' + label} onClick={onRemove}><i className="ph-bold ph-trash" aria-hidden="true" style={{ color: 'var(--coral-700)' }} /></Key>}
78
+ </div>
79
+ {media && (
80
+ <div style={{ display: 'grid', gridTemplateColumns: '88px minmax(0, 1fr)', gap: 'var(--space-3)', alignItems: 'center' }}>
81
+ <div style={{ width: 88, height: 88, borderRadius: 'var(--radius-sm)', background: 'var(--cream-200)', display: 'grid', placeItems: 'center', overflow: 'hidden', fontSize: 32, color: 'var(--stone-500)', position: 'relative' }}>
82
+ {thumb
83
+ ? <img src={thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', animation: 'mp-pop var(--dur-base) var(--ease-bounce) both' }} />
84
+ : <i className={'ph-fill ph-' + glyph} aria-hidden="true" />}
85
+ </div>
86
+ <div style={{ display: 'grid', gap: 4, minWidth: 0, fontSize: 13, color: 'var(--ink-500)' }}>
87
+ {name && <span title="Only you see filenames" style={{ fontWeight: 700, color: 'var(--ink-700)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{name}</span>}
88
+ <span>{details}</span>
89
+ <span role="status" style={{ fontWeight: 700 }}><Status state={state} pct={pct} reason={reason} /></span>
90
+ {state === 'uploading' && (
91
+ <>
92
+ <div role="progressbar" aria-label="Upload progress" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}
93
+ style={{ height: 12, borderRadius: 999, background: 'var(--cream-300)', boxShadow: 'inset 0 2px 3px oklch(0 0 0 / 0.12)', overflow: 'hidden' }}>
94
+ <span style={{ display: 'block', width: pct + '%', height: '100%', borderRadius: 999, transition: 'width var(--dur-base) var(--ease-out)',
95
+ background: 'repeating-linear-gradient(-45deg, oklch(1 0 0 / 0.28) 0 6px, transparent 6px 12px), linear-gradient(180deg, var(--teal-300), var(--teal-500))' }} />
96
+ </div>
97
+ {onCancel && (
98
+ <button type="button" onClick={onCancel} style={{ appearance: 'none', border: 0, background: 'none', padding: 0, minHeight: 32, justifySelf: 'start', ...FONT,
99
+ fontWeight: 700, fontSize: 15, color: 'var(--blue-700)', textDecoration: 'underline', textUnderlineOffset: 3, cursor: 'pointer' }}>Cancel</button>
100
+ )}
101
+ </>
102
+ )}
103
+ {(state === 'error' || state === 'stalled') && onRetry && (
104
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
105
+ <Button size="sm" variant="danger" onClick={onRetry}>Retry</Button>
106
+ </div>
107
+ )}
108
+ {error && <span style={WARN}>{error}</span>}
109
+ </div>
110
+ </div>
111
+ )}
112
+ {children}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
@@ -0,0 +1,21 @@
1
+ Upload tile: one block of a memory in the composer. Blocks stack in the contributor's order, and that's the order the recipient sees them in.
2
+
3
+ ```jsx
4
+ <ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 22 }}>
5
+ <li><UploadTile type="text" index={0} count={3} onMoveUp={up} onMoveDown={down} onRemove={remove}>
6
+ <TextField label={<span className="mp-sr">Text 1</span>} multiline rows={6} value={text} onChange={setText} />
7
+ </UploadTile></li>
8
+ <li><UploadTile type="photo" index={1} count={3} state="done" name="beach.jpg" details="2.4 MB" thumb={url} onMoveUp={up} onMoveDown={down} onRemove={remove}>
9
+ <TextField label="Caption" optional value={caption} onChange={setCaption} />
10
+ </UploadTile></li>
11
+ <li><UploadTile type="video" index={2} count={3} state="uploading" progress={0.42} details="0:42 · 12.6 MB" onCancel={cancel} onMoveUp={up} onMoveDown={down} onRemove={remove} /></li>
12
+ </ol>
13
+ ```
14
+
15
+ - Stack blocks 22px apart. Every block after the first draws a short stretch of road up to the one above.
16
+ - Key each list item by the block's id, not its place. When a block moves, focus stays on the key that moved it, even once that key can't go any further. Keys that can't act are `aria-disabled`, so they stay focusable.
17
+ - `state` covers every upload. `uploading` shows `progress`, and Cancel when you pass `onCancel`. Then `done`, `error` (pass the `reason` when there is one), `stalled` when it stopped before finishing, and `missing` when the file is gone.
18
+ - `error` and `missing` turn the block coral. Retry shows on `error` and `stalled` when you pass `onRetry`.
19
+ - A memory with a failed or missing upload can't be sent. Keep the block on screen until it's fixed or removed.
20
+ - Filenames only ever show to the contributor. Strip them from what the recipient receives, since they can carry names.
21
+ - Add blocks with a row of square `IconButton`s under the list, one per kind.
@@ -0,0 +1,18 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * A pill that shows the world a memory is in, with its glyph on a round medal, and changes it when pressed. Filled with the world's accent, with a small caret to say it can be changed.
5
+ * @startingPoint section="Contributor" subtitle="The world a memory is in, as a pill" viewport="700x160"
6
+ */
7
+ export interface WorldChipProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
8
+ world?: 'mango_grove' | 'small_chops' | 'good_energy' | 'japan';
9
+ /** Overrides the world's display name. */
10
+ name?: string;
11
+ /** Overrides the world's glyph. */
12
+ glyph?: string;
13
+ /** The little caret that says "tap to change". Default true. */
14
+ caret?: boolean;
15
+ onClick?: () => void;
16
+ }
17
+
18
+ export function WorldChip(props: WorldChipProps): React.JSX.Element;
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { WORLDS } from '../map/worlds.js';
4
+
5
+ /* A pill that shows the world a memory is in, and changes it when pressed. Give it an aria-label that says so,
6
+ such as "Where on the road: Mango Grove. Tap to change." */
7
+ export function WorldChip({ world = 'mango_grove', name, glyph, caret = true, onClick, ...rest }) {
8
+ const W = WORLDS[world] || WORLDS.mango_grove;
9
+ return (
10
+ <button type="button" className="mp-world-pill" data-world={world} onClick={onClick} {...rest}>
11
+ <span className="mp-world-medal" aria-hidden="true"><i className={'ph-fill ph-' + (glyph || W.glyph)} /></span>
12
+ <span className="mp-world-pill-name">{name || W.name}</span>
13
+ {caret && <span className="mp-pill-mark" aria-hidden="true"><i className="ph-bold ph-caret-down" /></span>}
14
+ </button>
15
+ );
16
+ }
@@ -0,0 +1,9 @@
1
+ World chip: shows which world a memory is in, and opens a way to change it.
2
+
3
+ ```jsx
4
+ <WorldChip world={memory.world} onClick={() => setPicking(true)} aria-label={`Where on the road: ${worldName}. Tap to change.`} />
5
+ ```
6
+
7
+ - Always filled with the world's accent, because it shows a choice that's already made. To choose a world in the first place, use `WorldSign`s or a list of `WorldOption`s.
8
+ - Give it an `aria-label` that says what it is and that pressing it changes the world. The visible name alone doesn't.
9
+ - The name is cut off with an ellipsis before it pushes anything else out of the row, so it gives way first on narrow phones.
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * One world in a list to choose from: its glyph on a round medal, its name and a line about it, with a radio on the right. The chosen row turns teal.
5
+ * @startingPoint section="Contributor" subtitle="A list of worlds to pick from" viewport="700x420"
6
+ */
7
+ export interface WorldOptionProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'onChange' | 'children'> {
8
+ world?: 'mango_grove' | 'small_chops' | 'good_energy' | 'japan';
9
+ /** Overrides the world's display name. */
10
+ name?: string;
11
+ /** Overrides the world's glyph. */
12
+ glyph?: string;
13
+ /** The line under the name. Defaults to the world's own. */
14
+ blurb?: string;
15
+ checked?: boolean;
16
+ onChange?: (checked: true) => void;
17
+ }
18
+
19
+ export function WorldOption(props: WorldOptionProps): React.JSX.Element;
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { WORLDS } from '../map/worlds.js';
4
+
5
+ /* One world in a list to pick from: its medal, name and a line about it, with a radio on the right.
6
+ Put a group of them in role="radiogroup". */
7
+ export function WorldOption({ world = 'mango_grove', name, glyph, blurb, checked = false, onChange, ...rest }) {
8
+ const W = WORLDS[world] || WORLDS.mango_grove;
9
+ return (
10
+ <button type="button" role="radio" aria-checked={checked} className="mp-world-option" data-world={world} onClick={() => onChange && onChange(true)} {...rest}>
11
+ <span className="mp-world-medal" aria-hidden="true"><i className={'ph-fill ph-' + (glyph || W.glyph)} /></span>
12
+ <span className="mp-world-option-text">
13
+ <strong>{name || W.name}</strong>
14
+ <span>{blurb === undefined ? W.blurb : blurb}</span>
15
+ </span>
16
+ <span className="mp-world-radio" aria-hidden="true">{checked && <i className="ph-bold ph-check" />}</span>
17
+ </button>
18
+ );
19
+ }
@@ -0,0 +1,11 @@
1
+ World option: a row in a list of worlds, for a picker that opens over the screen.
2
+
3
+ ```jsx
4
+ <div role="radiogroup" aria-label="Where on the road?" style={{ display: 'grid', gap: 10 }}>
5
+ {worlds.map(w => <WorldOption key={w} world={w} checked={chosen === w} onChange={() => setChosen(w)} />)}
6
+ </div>
7
+ ```
8
+
9
+ - Put the rows in a `role="radiogroup"` with a label. Each one is a radio button.
10
+ - The chosen row turns teal and its radio pops a tick.
11
+ - Use `WorldSign`s when there's room to make the choice a moment, and `WorldOption`s when it's a quick change in a sheet.
@@ -0,0 +1,25 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * A sign for choosing a world: a plank on two posts with the world's glyph, name and a line about it. A chosen sign glows and gets a tick. A locked sign, like the summit, is a stone plank that does nothing.
5
+ * @startingPoint section="Contributor" subtitle="Choose a world, on signs hung over one road" viewport="700x560"
6
+ */
7
+ export interface WorldSignProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
8
+ world?: 'mango_grove' | 'small_chops' | 'good_energy' | 'japan';
9
+ /** Overrides the world's display name. */
10
+ name?: string;
11
+ /** Overrides the world's glyph. A locked sign always shows a lock. */
12
+ glyph?: string;
13
+ /** The line on the cream tag under the name. Defaults to the world's own. Pass "" for none. */
14
+ blurb?: string;
15
+ /** A number on a small marker at the sign's top left, for the order along the road. */
16
+ number?: number;
17
+ selected?: boolean;
18
+ /** A stone sign that can't be chosen. It has no button and no number. */
19
+ locked?: boolean;
20
+ /** Hangs off the sign's bottom left, such as a "Your stop" Tag. */
21
+ badge?: React.ReactNode;
22
+ onClick?: () => void;
23
+ }
24
+
25
+ export function WorldSign(props: WorldSignProps): React.JSX.Element;
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { WORLDS } from '../map/worlds.js';
4
+
5
+ /* A sign for choosing a world: a plank on two posts with the world's name and a line about it. Press it to choose.
6
+ Set `locked` for a world that can't be chosen, such as the summit, and it becomes a stone sign that does nothing. */
7
+ export function WorldSign({ world = 'mango_grove', name, glyph, blurb, number, selected = false, locked = false, badge, onClick, ...rest }) {
8
+ const W = WORLDS[world] || WORLDS.mango_grove;
9
+ const title = name || W.name;
10
+ const icon = locked ? 'lock' : (glyph || W.glyph);
11
+ const text = blurb === undefined ? W.blurb : blurb;
12
+ const posts = [<span key="a" className="mp-sign-post" aria-hidden="true" />, <span key="b" className="mp-sign-post" aria-hidden="true" />];
13
+ const plank = (
14
+ <span className={'mp-sign-plank' + (locked ? ' mp-sign-plank-locked' : '')}>
15
+ <span className="mp-sign-title" style={title.length > 16 ? { fontSize: 19 } : undefined}>
16
+ <i className={'ph-fill ph-' + icon} aria-hidden="true" />
17
+ {title}
18
+ </span>
19
+ {text && <span className="mp-sign-blurb">{text}</span>}
20
+ {badge && <span className="mp-sign-badge">{badge}</span>}
21
+ {selected && <span className="mp-sign-check" aria-hidden="true"><i className="ph-bold ph-check" /></span>}
22
+ </span>
23
+ );
24
+ if (locked) return <div className="mp-sign" style={{ cursor: 'default' }} {...rest}>{posts}{plank}</div>;
25
+ return (
26
+ <button type="button" className="mp-sign" data-world={world} aria-pressed={selected} onClick={onClick} {...rest}>
27
+ {posts}
28
+ {number !== undefined && <span className="mp-sign-number" aria-hidden="true"><span className="mp-step">{number}</span></span>}
29
+ {plank}
30
+ </button>
31
+ );
32
+ }
@@ -0,0 +1,14 @@
1
+ World sign: a big, friendly way to choose a world. Hang a column of them over a road with `WorldSigns`.
2
+
3
+ ```jsx
4
+ <WorldSigns label="Where on the road">
5
+ {worlds.map((w, i) => <WorldSign key={w} world={w} number={i + 1} selected={chosen === w} onClick={() => setChosen(w)} />)}
6
+ <WorldSign locked name="The Summit" blurb="The author's letter" />
7
+ </WorldSigns>
8
+ ```
9
+
10
+ - Exactly one sign is chosen at a time. A chosen sign glows teal and gets a tick. It reports as a pressed button.
11
+ - `number` puts a small marker at the top left, so the signs read in order along the road. Leave it out when order doesn't matter.
12
+ - `badge` hangs a small label off the bottom left. Use it to flag one sign, such as the world a friend's main memory is already in.
13
+ - A locked sign is for a world that can't be chosen. It's stone, shows a lock, and takes no presses.
14
+ - Pressing a sign moves it down, the same way every other pressable does.
@@ -0,0 +1,13 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Hangs a column of WorldSigns over one road, with a straight stretch of road showing between the signs.
5
+ * @startingPoint section="Contributor" subtitle="Signs hung over one road" viewport="700x560"
6
+ */
7
+ export interface WorldSignsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
8
+ /** The group's accessible name, such as "Where on the road". */
9
+ label: string;
10
+ children?: React.ReactNode;
11
+ }
12
+
13
+ export function WorldSigns(props: WorldSignsProps): React.JSX.Element;
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+ import React from 'react';
3
+
4
+ /* Hangs a column of WorldSigns over one road, with a straight stretch of it showing between the signs. */
5
+ export function WorldSigns({ label, children, ...rest }) {
6
+ return (
7
+ <div className="mp-signs mp-signs-road" role="group" aria-label={label} {...rest}>
8
+ <span className="mp-signs-spine" aria-hidden="true" />
9
+ {children}
10
+ </div>
11
+ );
12
+ }
@@ -0,0 +1,8 @@
1
+ World signs: the road the signs hang over. Put `WorldSign`s inside it.
2
+
3
+ ```jsx
4
+ <WorldSigns label="Where on the road">…signs…</WorldSigns>
5
+ ```
6
+
7
+ - It draws a straight stretch of road behind the signs, in the road colours of the world it sits in. Put it inside a `data-world`, such as Base Camp.
8
+ - Give it a `label`. It's a group, and the label is what a screen reader announces.
@@ -0,0 +1,14 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * A one-shot burst of coloured paper from the middle of its parent, for a reveal or a win. Hidden for reduced motion.
5
+ * @startingPoint section="Effects" subtitle="One-shot paper burst" viewport="700x360"
6
+ */
7
+ export interface ConfettiProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'children'> {
8
+ /** How many pieces. Default 46. */
9
+ pieces?: number;
10
+ /** Seeds the random spread, so a burst looks the same each time. Change it for a different pattern. */
11
+ seed?: string;
12
+ }
13
+
14
+ export function Confetti(props: ConfettiProps): React.JSX.Element;
@@ -0,0 +1,38 @@
1
+ 'use client';
2
+ import React, { useMemo } from 'react';
3
+
4
+ const COLOURS = ['#ffd84d', '#ff7aa8', '#4fb0e8', '#58c26a', '#ff9a3d', '#b884e8', '#ffffff'];
5
+
6
+ // A small deterministic random sequence, so the same burst is drawn on the server and in the browser.
7
+ function seeded(seed) {
8
+ let h = 2166136261;
9
+ for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
10
+ return () => {
11
+ h += 0x6d2b79f5;
12
+ let t = h;
13
+ t = Math.imul(t ^ (t >>> 15), t | 1);
14
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
18
+
19
+ /* A one-shot burst of paper from the middle of its parent. Put it inside a positioned box, behind what is being celebrated.
20
+ It is hidden entirely for reduced motion. */
21
+ export function Confetti({ pieces = 46, seed = 'confetti', ...rest }) {
22
+ const bits = useMemo(() => {
23
+ const r = seeded(seed);
24
+ return Array.from({ length: pieces }, (_, i) => {
25
+ const angle = r() * Math.PI * 2;
26
+ const dist = 140 + r() * 220;
27
+ return { i, colour: COLOURS[i % COLOURS.length], dx: Math.cos(angle) * dist, dy: Math.sin(angle) * dist - 80, rot: (r() - 0.5) * 900, delay: r() * 120, w: 8 + r() * 8, round: r() < 0.3 };
28
+ });
29
+ }, [pieces, seed]);
30
+ return (
31
+ <span className="mp-confetti" aria-hidden="true" {...rest}>
32
+ {bits.map(b => (
33
+ <span key={b.i} style={{ '--dx': b.dx.toFixed(0) + 'px', '--dy': b.dy.toFixed(0) + 'px', '--rot': b.rot.toFixed(0) + 'deg', background: b.colour, width: b.w, height: b.round ? b.w : b.w * 0.5,
34
+ borderRadius: b.round ? '50%' : 2, animationDelay: b.delay.toFixed(0) + 'ms' }} />
35
+ ))}
36
+ </span>
37
+ );
38
+ }
@@ -0,0 +1,12 @@
1
+ Confetti: paper that bursts out once, from the middle of its parent.
2
+
3
+ ```jsx
4
+ <div style={{ position: 'relative' }}>
5
+ <Confetti />
6
+ <RevealCard />
7
+ </div>
8
+ ```
9
+
10
+ - Put it in a positioned box and render it at the moment of the win. It plays once when it mounts; mount it again (change its `key`) to replay.
11
+ - It sits just above its parent's content and ignores pointer events, so it never blocks a tap.
12
+ - It is hidden for reduced motion, so don't rely on it to say anything the screen doesn't.
@@ -0,0 +1,9 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Five gold twinkles scattered around a celebrated panel. Decorative, and still for reduced motion.
5
+ * @startingPoint section="Effects" subtitle="Twinkles around a panel" viewport="700x300"
6
+ */
7
+ export interface SparklesProps {}
8
+
9
+ export function Sparkles(props: SparklesProps): React.JSX.Element;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ import React from 'react';
3
+
4
+ const SPOTS = [['6%', '18px'], ['88%', '4px'], ['16%', '92px'], ['80%', '110px'], ['50%', '-6px']];
5
+
6
+ /* A handful of twinkles around a celebrated panel. Decorative only: put it inside a positioned box. */
7
+ export function Sparkles() {
8
+ return (
9
+ <>
10
+ {SPOTS.map(([left, top], i) => <i key={i} className="ph-fill ph-sparkle mp-sparkle-spot" aria-hidden="true" style={{ left, top, '--i': i }} />)}
11
+ </>
12
+ );
13
+ }
@@ -0,0 +1,11 @@
1
+ Sparkles: twinkles around a panel that is being celebrated.
2
+
3
+ ```jsx
4
+ <div style={{ position: 'relative' }}>
5
+ <Sparkles />
6
+ <Notice tone="gold" icon="gift">Everything is in.</Notice>
7
+ </div>
8
+ ```
9
+
10
+ - It places itself over its nearest positioned parent, so give the parent `position: relative` and a little room around it.
11
+ - Decorative: it is hidden from screen readers and has no props.
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Soft rays of light that fan out from a point and turn slowly. SVG, so it goes inside an svg, behind the thing it lights.
5
+ * @startingPoint section="Effects" subtitle="Turning rays of light, for SVG" viewport="700x320"
6
+ */
7
+ export interface SunburstProps {
8
+ /** Centre, in the svg's own units. Default 0, 0. */
9
+ x?: number;
10
+ y?: number;
11
+ /** How far the rays reach. Default 500. */
12
+ radius?: number;
13
+ /** How many rays, 3 to 24. Default 12. */
14
+ rays?: number;
15
+ color?: string;
16
+ /** Default 0.13: a faint glow. */
17
+ opacity?: number;
18
+ /** Seconds for one turn (continuous) or the whole fade (once). Default 120. */
19
+ duration?: number;
20
+ motion?: 'continuous' | 'once' | 'still';
21
+ }
22
+
23
+ export function Sunburst(props: SunburstProps): React.JSX.Element;