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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henry Apata
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Chunky bevelled game button. Solid darker bottom edge, top highlight, presses down 4px.
5
+ * @startingPoint section="Buttons" subtitle="Bevelled CTA in 8 tones and 3 sizes" viewport="700x260"
6
+ */
7
+ export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
8
+ /** Tone. primary = gold (default CTA), play = sky blue, confirm = teal OK, danger = coral, premium = berry, wood = brown frame, ghost = cream, locked = stone. */
9
+ variant?: 'primary' | 'play' | 'confirm' | 'danger' | 'premium' | 'wood' | 'ghost' | 'locked';
10
+ /** sm 36px / md 48px / lg 60px tall. */
11
+ size?: 'sm' | 'md' | 'lg';
12
+ /** display = Titan One uppercase (default); ui = Quicksand bold sentence case for longer labels. */
13
+ typeface?: 'display' | 'ui';
14
+ /** Leading icon node (e.g. <GameIcon name="play" size={20} />). */
15
+ icon?: React.ReactNode;
16
+ /** Trailing node (e.g. a price or a CurrencyPill). */
17
+ trailing?: React.ReactNode;
18
+ children?: React.ReactNode;
19
+ disabled?: boolean;
20
+ /** Stretch to container width. */
21
+ block?: boolean;
22
+ /** Fully rounded ends. */
23
+ pill?: boolean;
24
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
25
+ style?: React.CSSProperties;
26
+ }
27
+
28
+ export function Button(props: ButtonProps): React.JSX.Element;
@@ -0,0 +1,50 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ const TONES = {
5
+ primary: ['var(--gold-300)', 'var(--gold-500)', 'var(--gold-700)', 'var(--gold-900)'],
6
+ play: ['var(--blue-300)', 'var(--blue-500)', 'var(--blue-700)', '#fff'],
7
+ confirm: ['var(--teal-300)', 'var(--teal-500)', 'var(--teal-700)', '#fff'],
8
+ danger: ['var(--coral-300)', 'var(--coral-500)', 'var(--coral-700)', '#fff'],
9
+ premium: ['var(--berry-300)', 'var(--berry-500)', 'var(--berry-700)', '#fff'],
10
+ wood: ['var(--wood-300)', 'var(--wood-500)', 'var(--wood-700)', '#fff'],
11
+ ghost: ['#fff', 'var(--cream-100)', 'var(--cream-300)', 'var(--ink-900)'],
12
+ locked: ['var(--stone-300)', 'var(--stone-500)', 'var(--stone-700)', '#fff'],
13
+ };
14
+ // [height, padding-x, font-size, bevel depth]
15
+ const SIZES = { sm: [36, 14, 14, 3], md: [48, 22, 18, 5], lg: [60, 30, 24, 7] };
16
+
17
+ export function Button({ variant = 'primary', size = 'md', typeface = 'display', icon, trailing, children, disabled = false, block = false, pill = false, onClick, style, ...rest }) {
18
+ const [down, setDown] = useState(false);
19
+ const [hover, setHover] = useState(false);
20
+ const [top, mid, shade, text] = TONES[disabled ? 'locked' : variant] || TONES.primary;
21
+ const [h, px, fs, depth] = SIZES[size] || SIZES.md;
22
+ const lift = down && !disabled ? 1 : depth;
23
+ const display = typeface === 'display';
24
+ return (
25
+ <button type="button" disabled={disabled} onClick={onClick}
26
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerCancel={() => setDown(false)}
27
+ onPointerEnter={() => setHover(true)} onPointerLeave={() => { setDown(false); setHover(false); }}
28
+ style={{
29
+ appearance: 'none', border: 0, margin: 0, cursor: disabled ? 'default' : 'pointer',
30
+ display: block ? 'flex' : 'inline-flex', width: block ? '100%' : undefined, boxSizing: 'border-box',
31
+ alignItems: 'center', justifyContent: 'center', gap: 'var(--space-2)',
32
+ height: h, minWidth: h, padding: '0 ' + px + 'px',
33
+ borderRadius: pill ? 'var(--radius-pill)' : 'var(--radius-md)',
34
+ background: 'linear-gradient(180deg, ' + top + ' 0%, ' + mid + ' 42%, ' + mid + ' 100%)',
35
+ boxShadow: '0 ' + lift + 'px 0 ' + shade + ', 0 ' + (lift + 6) + 'px 14px oklch(0.2 0.04 60 / 0.25), inset 0 2px 0 oklch(1 0 0 / 0.45)',
36
+ transform: 'translateY(' + (depth - lift) + 'px)',
37
+ filter: hover && !disabled && !down ? 'brightness(1.06)' : 'none',
38
+ transition: 'transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), filter var(--dur-fast)',
39
+ fontFamily: display ? 'var(--font-display)' : 'var(--font-ui)',
40
+ fontWeight: display ? 400 : 700,
41
+ fontSize: display ? fs : fs - 1, lineHeight: 1, letterSpacing: display ? '0.02em' : 0,
42
+ textTransform: display ? 'uppercase' : 'none',
43
+ color: text, textShadow: text === '#fff' ? '0 2px 0 ' + shade : 'none',
44
+ opacity: disabled ? 0.85 : 1, userSelect: 'none', WebkitTapHighlightColor: 'transparent', whiteSpace: 'nowrap',
45
+ ...style,
46
+ }} {...rest}>
47
+ {icon}{children}{trailing}
48
+ </button>
49
+ );
50
+ }
@@ -0,0 +1,16 @@
1
+ Chunky bevelled game button: use for every tappable action (CTA, OK, Claim, Play, Buy); never a flat or outlined button.
2
+
3
+ ```jsx
4
+ <Button variant="primary" size="lg" block>Claim</Button>
5
+ <Button variant="play" icon={<GameIcon name="play" size={20} />}>Level 22</Button>
6
+ <Button variant="confirm">OK</Button>
7
+ <Button variant="danger" size="sm">Cancel</Button>
8
+ <Button variant="premium" trailing={<span>USD 3.99</span>} typeface="ui">3,000 coins</Button>
9
+ <Button variant="wood" pill>Shop</Button>
10
+ <Button disabled>Locked</Button>
11
+ ```
12
+
13
+ - Variants: primary (gold, dark text: the default CTA), play (blue), confirm (teal), danger (coral), premium (berry), wood, ghost (cream), locked (stone).
14
+ - Sizes: sm 36 / md 48 / lg 60. One lg button per screen (the main action); md for panel actions; sm inside rows.
15
+ - Labels are 1–2 words, Title Case in source; display typeface renders them uppercase.
16
+ - Pressed state is built in (travels down, bevel collapses). Do not add your own hover styles.
@@ -0,0 +1,23 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Round (or rounded-square) bevelled button holding a single glyph; optional notification badge and outlined caption.
5
+ * @startingPoint section="Buttons" subtitle="Round glyph button with badge + caption" viewport="700x260"
6
+ */
7
+ export interface IconButtonProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style'> {
8
+ variant?: 'primary' | 'play' | 'confirm' | 'danger' | 'premium' | 'wood' | 'ghost' | 'locked';
9
+ /** sm 36px / md 48px / lg 60px. */
10
+ size?: 'sm' | 'md' | 'lg';
11
+ shape?: 'circle' | 'square';
12
+ /** true renders a red "!" dot; a number/string renders a count. */
13
+ badge?: boolean | number | string;
14
+ /** Outlined uppercase caption under the button (e.g. SHOP, QUESTS). Also used as aria-label. */
15
+ label?: string;
16
+ /** The glyph, e.g. <i className="ph-fill ph-gear" /> or <GameIcon name="gift" />. */
17
+ children?: React.ReactNode;
18
+ disabled?: boolean;
19
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
20
+ style?: React.CSSProperties;
21
+ }
22
+
23
+ export function IconButton(props: IconButtonProps): React.JSX.Element;
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ const TONES = {
5
+ primary: ['var(--gold-300)', 'var(--gold-500)', 'var(--gold-700)', 'var(--gold-900)'],
6
+ play: ['var(--blue-300)', 'var(--blue-500)', 'var(--blue-700)', '#fff'],
7
+ confirm: ['var(--teal-300)', 'var(--teal-500)', 'var(--teal-700)', '#fff'],
8
+ danger: ['var(--coral-300)', 'var(--coral-500)', 'var(--coral-700)', '#fff'],
9
+ premium: ['var(--berry-300)', 'var(--berry-500)', 'var(--berry-700)', '#fff'],
10
+ wood: ['var(--wood-300)', 'var(--wood-500)', 'var(--wood-700)', '#fff'],
11
+ ghost: ['#fff', 'var(--cream-100)', 'var(--cream-300)', 'var(--ink-900)'],
12
+ locked: ['var(--stone-300)', 'var(--stone-500)', 'var(--stone-700)', '#fff'],
13
+ };
14
+ // [diameter, bevel depth, icon font-size]
15
+ const SIZES = { sm: [36, 3, 18], md: [48, 5, 24], lg: [60, 7, 30] };
16
+
17
+ export function IconButton({ variant = 'play', size = 'md', shape = 'circle', badge, label, children, disabled = false, onClick, style, ...rest }) {
18
+ const [down, setDown] = useState(false);
19
+ const [top, mid, shade, text] = TONES[disabled ? 'locked' : variant] || TONES.play;
20
+ const [d, depth, fs] = SIZES[size] || SIZES.md;
21
+ const lift = down && !disabled ? 1 : depth;
22
+ const btn = (
23
+ <button type="button" disabled={disabled} onClick={onClick} aria-label={label}
24
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerCancel={() => setDown(false)} onPointerLeave={() => setDown(false)}
25
+ style={{
26
+ appearance: 'none', border: 0, margin: 0, padding: 0, cursor: disabled ? 'default' : 'pointer', position: 'relative',
27
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: d, height: d, flex: 'none',
28
+ borderRadius: shape === 'circle' ? '50%' : 'var(--radius-md)',
29
+ background: 'linear-gradient(180deg, ' + top + ' 0%, ' + mid + ' 45%)',
30
+ boxShadow: '0 ' + lift + 'px 0 ' + shade + ', 0 ' + (lift + 5) + 'px 12px oklch(0.2 0.04 60 / 0.25), inset 0 2px 0 oklch(1 0 0 / 0.45)',
31
+ transform: 'translateY(' + (depth - lift) + 'px)',
32
+ transition: 'transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out)',
33
+ color: text, fontSize: fs, lineHeight: 1, textShadow: text === '#fff' ? '0 2px 0 ' + shade : 'none',
34
+ opacity: disabled ? 0.85 : 1, userSelect: 'none', WebkitTapHighlightColor: 'transparent',
35
+ ...(label ? {} : style),
36
+ }} {...(label ? {} : rest)}>
37
+ {children}
38
+ {badge != null && badge !== false && (
39
+ <span style={{ position: 'absolute', top: -6, right: -6, minWidth: 22, height: 22, padding: '0 6px', boxSizing: 'border-box', borderRadius: 'var(--radius-pill)',
40
+ background: 'linear-gradient(180deg, var(--coral-300), var(--coral-500) 50%)', boxShadow: '0 2px 0 var(--coral-700), 0 3px 6px oklch(0 0 0 / 0.25)', border: '2px solid #fff',
41
+ color: '#fff', fontFamily: 'var(--font-display)', fontSize: 12, lineHeight: '18px', textAlign: 'center' }}>{badge === true ? '!' : badge}</span>
42
+ )}
43
+ </button>
44
+ );
45
+ if (!label) return btn;
46
+ return (
47
+ <div style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 6, ...style }} {...rest}>
48
+ {btn}
49
+ <span style={{ fontFamily: 'var(--font-display)', fontSize: 13, letterSpacing: '0.04em', textTransform: 'uppercase', color: '#fff',
50
+ WebkitTextStroke: '0.2em var(--ink-900)', paintOrder: 'stroke fill', textShadow: '0 2px 0 oklch(0 0 0 / 0.25)' }}>{label}</span>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,12 @@
1
+ Round bevelled glyph button: for close (×), settings, plus (+), and the captioned side-rail actions (SPIN / SHOP / QUESTS).
2
+
3
+ ```jsx
4
+ <IconButton variant="danger" size="sm"><i className="ph-fill ph-x" /></IconButton>
5
+ <IconButton variant="ghost"><i className="ph-fill ph-gear" /></IconButton>
6
+ <IconButton variant="primary" shape="square" label="Shop" badge><i className="ph-fill ph-storefront" /></IconButton>
7
+ <IconButton variant="play" label="Quests" badge={3}><i className="ph-fill ph-clipboard-text" /></IconButton>
8
+ ```
9
+
10
+ - Close buttons sit on the panel's top-right corner, overlapping the edge: variant="danger" or "play", size="sm".
11
+ - Captioned buttons (label) stack the outlined caption below; use in vertical side rails and bottom bars.
12
+ - badge renders the red notification dot (true → "!", number → count).
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * A pill-shaped tab switch for two or three views of the same list, such as People and Keepsakes. The chosen tab lifts onto a white pill.
5
+ * @startingPoint section="Buttons" subtitle="Pill tab switch" viewport="700x140"
6
+ */
7
+ export interface SegmentedTab {
8
+ id: string;
9
+ label: React.ReactNode;
10
+ }
11
+
12
+ export interface SegmentedTabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
13
+ items: SegmentedTab[];
14
+ /** The id of the tab that is on. */
15
+ value: string;
16
+ onChange?: (id: string) => void;
17
+ /** Accessible name for the group, such as "Collection". */
18
+ label?: string;
19
+ }
20
+
21
+ export function SegmentedTabs(props: SegmentedTabsProps): React.JSX.Element;
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+ import React from 'react';
3
+
4
+ /* A pill-shaped tab switch for two or three views of the same thing. items are { id, label }; the one whose id equals value is on. */
5
+ export function SegmentedTabs({ items = [], value, onChange, label, className, ...rest }) {
6
+ return (
7
+ <div className={['mp-seg', className].filter(Boolean).join(' ')} role="tablist" aria-label={label} {...rest}>
8
+ {items.map(it => (
9
+ <button key={it.id} type="button" role="tab" aria-selected={value === it.id} className={'mp-seg-tab' + (value === it.id ? ' mp-seg-on' : '')} onClick={() => onChange && onChange(it.id)}>
10
+ {it.label}
11
+ </button>
12
+ ))}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,10 @@
1
+ Segmented tabs: switch between two or three views in place.
2
+
3
+ ```jsx
4
+ <SegmentedTabs label="Collection" value={tab} onChange={setTab}
5
+ items={[{ id: 'people', label: 'People · 3 of 8' }, { id: 'keepsakes', label: 'Keepsakes · 2' }]} />
6
+ ```
7
+
8
+ - Controlled: keep the chosen id in state and render the matching view yourself.
9
+ - Keep labels short. The group is as wide as its tabs and never wider than its container.
10
+ - For moving between steps of a flow, use `StepBar`; this is for views of one thing.
@@ -0,0 +1,27 @@
1
+ import * as React from 'react';
2
+ import { RecorderPanelProps } from './RecorderPanel';
3
+
4
+ /** What onDone receives: the take, ready to upload. */
5
+ export interface RecordedAudio {
6
+ blob: Blob;
7
+ kind: 'audio';
8
+ /** The format that was recorded, such as "audio/mp4" or "audio/webm". */
9
+ mimeType: string;
10
+ /** "voice-note.m4a", ".ogg" or ".webm", to match the format. */
11
+ filename: string;
12
+ durationSeconds: number;
13
+ }
14
+
15
+ /**
16
+ * A voice-note recorder: record, listen back, then use or discard. It asks for the microphone when Record is pressed, stops by itself at the limit, and keeps the take in the browser until Use this.
17
+ * @startingPoint section="Contributor" subtitle="Working voice-note recorder" viewport="700x300"
18
+ */
19
+ export interface AudioRecorderProps extends Omit<RecorderPanelProps, 'phase' | 'supported' | 'seconds' | 'url' | 'error' | 'onRecord' | 'onStop' | 'onRedo' | 'onUse'> {
20
+ /** Called with the take when Use this is pressed. */
21
+ onDone?: (audio: RecordedAudio) => void;
22
+ onCancel?: () => void;
23
+ /** The longest take, in seconds. Default 600. */
24
+ maxSeconds?: number;
25
+ }
26
+
27
+ export function AudioRecorder(props: AudioRecorderProps): React.JSX.Element;
@@ -0,0 +1,79 @@
1
+ 'use client';
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import { RecorderPanel } from './RecorderPanel';
4
+
5
+ const MIME_TYPES = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'];
6
+
7
+ function pickMimeType() {
8
+ if (typeof MediaRecorder === 'undefined') return undefined;
9
+ return MIME_TYPES.find(t => MediaRecorder.isTypeSupported(t));
10
+ }
11
+
12
+ const baseMimeType = mime => mime.split(';')[0].trim().toLowerCase();
13
+
14
+ /* A voice-note recorder: record, listen back, then use or discard. Nothing leaves the browser until onDone is called with the take.
15
+ onDone gets { blob, kind: 'audio', mimeType, filename, durationSeconds }. It asks for the microphone when Record is pressed. */
16
+ export function AudioRecorder({ onDone, onCancel, prompt, maxSeconds = 10 * 60, ...rest }) {
17
+ const [phase, setPhase] = useState('idle');
18
+ const [seconds, setSeconds] = useState(0);
19
+ const [error, setError] = useState(null);
20
+ const [result, setResult] = useState(null);
21
+ const recorder = useRef(null);
22
+ const stream = useRef(null);
23
+ const chunks = useRef([]);
24
+ const tick = useRef(null);
25
+
26
+ const supported = typeof window !== 'undefined' && typeof MediaRecorder !== 'undefined' && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
27
+
28
+ useEffect(() => () => {
29
+ if (tick.current) clearInterval(tick.current);
30
+ if (stream.current) stream.current.getTracks().forEach(t => t.stop());
31
+ }, []);
32
+
33
+ useEffect(() => () => { if (result) URL.revokeObjectURL(result.url); }, [result]);
34
+
35
+ useEffect(() => { if (phase === 'recording' && seconds >= maxSeconds) stop(); }, [phase, seconds, maxSeconds]);
36
+
37
+ async function start() {
38
+ setError(null);
39
+ try {
40
+ stream.current = await navigator.mediaDevices.getUserMedia({ audio: true });
41
+ } catch {
42
+ setError('Microphone access was blocked. Allow it in your browser settings, or upload an audio file instead.');
43
+ return;
44
+ }
45
+ const mimeType = pickMimeType();
46
+ const rec = new MediaRecorder(stream.current, mimeType ? { mimeType } : undefined);
47
+ chunks.current = [];
48
+ rec.ondataavailable = e => { if (e.data.size) chunks.current.push(e.data); };
49
+ rec.onstop = () => {
50
+ if (stream.current) stream.current.getTracks().forEach(t => t.stop());
51
+ const mime = baseMimeType(rec.mimeType || mimeType || 'audio/webm');
52
+ const blob = new Blob(chunks.current, { type: mime });
53
+ setResult({ blob, url: URL.createObjectURL(blob), mime });
54
+ setPhase('review');
55
+ };
56
+ recorder.current = rec;
57
+ rec.start(1000);
58
+ setSeconds(0);
59
+ setPhase('recording');
60
+ tick.current = setInterval(() => setSeconds(n => n + 1), 1000);
61
+ }
62
+
63
+ function stop() {
64
+ if (tick.current) clearInterval(tick.current);
65
+ tick.current = null;
66
+ if (recorder.current && recorder.current.state === 'recording') recorder.current.stop();
67
+ }
68
+
69
+ function use() {
70
+ if (!result) return;
71
+ const ext = result.mime.includes('mp4') ? 'm4a' : result.mime.includes('ogg') ? 'ogg' : 'webm';
72
+ onDone && onDone({ blob: result.blob, kind: 'audio', mimeType: result.mime, filename: 'voice-note.' + ext, durationSeconds: seconds });
73
+ }
74
+
75
+ return (
76
+ <RecorderPanel phase={phase} supported={supported} prompt={prompt} seconds={seconds} maxSeconds={maxSeconds} url={result && result.url} error={error}
77
+ onRecord={start} onStop={stop} onRedo={() => { setResult(null); setPhase('idle'); }} onUse={use} onCancel={onCancel} {...rest} />
78
+ );
79
+ }
@@ -0,0 +1,13 @@
1
+ Audio recorder: record a voice note in the browser, listen back, then use or discard it.
2
+
3
+ ```jsx
4
+ <AudioRecorder
5
+ prompt="Record a voice note. It will be played as it is."
6
+ onDone={file => addBlock(file)}
7
+ onCancel={() => setRecording(false)} />
8
+ ```
9
+
10
+ - Nothing is uploaded here. `onDone` hands back the take (`blob`, `mimeType`, `filename`, `durationSeconds`) and the app decides what to do with it.
11
+ - The microphone is only asked for when Record is pressed. If it's blocked, the panel says so and points to uploading a file instead.
12
+ - It picks the best format the browser can record (m4a, then webm, then ogg) and stops by itself at `maxSeconds`.
13
+ - Tell people in `prompt` anything they should know before recording, such as that their voice may be recognised.
@@ -0,0 +1,17 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Tall, card-sized radio option: media on top, then a title and a short description, with the radio in the top-right corner. Teal with a glow when chosen.
5
+ * @startingPoint section="Contributor" subtitle="Radio cards for an honest either/or" viewport="700x300"
6
+ */
7
+ export interface RadioCardProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'style' | 'onChange' | 'title'> {
8
+ checked?: boolean;
9
+ onChange?: (checked: true) => void;
10
+ title: React.ReactNode;
11
+ description?: React.ReactNode;
12
+ /** Shown above the title, usually a 72px Avatar. */
13
+ media?: React.ReactNode;
14
+ style?: React.CSSProperties;
15
+ }
16
+
17
+ export function RadioCard(props: RadioCardProps): React.JSX.Element;
@@ -0,0 +1,30 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ const DROP = '0 14px 24px oklch(0.22 0.05 60 / 0.3)';
5
+ const GLOW = '0 0 0 4px oklch(0.76 0.13 180 / 0.5), 0 0 28px oklch(0.76 0.13 180 / 0.6)';
6
+
7
+ export function RadioCard({ checked = false, onChange, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, title, description, media, style, ...rest }) {
8
+ const [down, setDown] = useState(false);
9
+ // Your own handlers run alongside the card's, so passing one never breaks choosing or the press.
10
+ const press = (on, yours) => e => { setDown(on); if (yours) yours(e); };
11
+ return (
12
+ <button type="button" role="radio" aria-checked={checked} onClick={e => { if (onClick) onClick(e); if (onChange) onChange(true); }}
13
+ onPointerDown={press(true, onPointerDown)} onPointerUp={press(false, onPointerUp)} onPointerCancel={press(false, onPointerCancel)} onPointerLeave={press(false, onPointerLeave)}
14
+ style={{ appearance: 'none', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '22px 12px 16px', boxSizing: 'border-box',
15
+ borderRadius: 'var(--radius-lg)', border: '4px solid ' + (checked ? 'var(--teal-500)' : 'var(--cream-300)'),
16
+ background: checked ? 'linear-gradient(180deg, var(--teal-100), #fff)' : 'linear-gradient(180deg, #fff, var(--cream-100))',
17
+ boxShadow: checked ? '0 5px 0 var(--teal-700), ' + DROP + ', ' + GLOW : '0 5px 0 var(--cream-300), ' + DROP,
18
+ font: 'inherit', textAlign: 'center', cursor: 'pointer', WebkitTapHighlightColor: 'transparent',
19
+ transition: 'transform var(--dur-fast) var(--ease-out)', transform: down ? 'translateY(3px)' : undefined, ...style }} {...rest}>
20
+ <span aria-hidden="true" style={{ position: 'absolute', right: 10, top: 10, width: 28, height: 28, boxSizing: 'border-box', borderRadius: '50%',
21
+ border: '3px solid ' + (checked ? 'var(--teal-500)' : 'var(--stone-500)'), background: checked ? 'var(--teal-500)' : '#fff', boxShadow: 'inset 0 2px 3px oklch(0 0 0 / 0.1)',
22
+ display: 'grid', placeItems: 'center', color: '#fff', fontSize: 14, animation: checked ? 'mp-radio-pop var(--dur-base) var(--ease-bounce) both' : undefined }}>
23
+ {checked && <i className="ph-bold ph-check" />}
24
+ </span>
25
+ {media}
26
+ <strong style={{ fontSize: 16, lineHeight: 1.2, color: 'var(--ink-900)' }}>{title}</strong>
27
+ {description && <span style={{ fontSize: 13, lineHeight: 1.4, fontWeight: 600, color: 'var(--ink-700)' }}>{description}</span>}
28
+ </button>
29
+ );
30
+ }
@@ -0,0 +1,12 @@
1
+ Radio card: a tall card for an honest either/or. Put two side by side in a radiogroup with a visible question.
2
+
3
+ ```jsx
4
+ <div role="radiogroup" aria-label="Does this memory show who you are?" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 'var(--space-3)' }}>
5
+ <RadioCard checked={!reveals} onChange={() => setReveals(false)} media={<Avatar mystery size={72} aria-hidden="true" />} title="Keeps me a mystery" description="No face, name or voice in here." />
6
+ <RadioCard checked={reveals} onChange={() => setReveals(true)} media={<Avatar name="Ada Okafor" size={72} alt="" aria-hidden="true" />} title="Shows who I am" description="Skip the guessing for this one." />
7
+ </div>
8
+ ```
9
+
10
+ - A chosen card turns teal with a glow, and its radio pops a tick. That's the system's "selected", so there's no tone to pick.
11
+ - Keep each description to a sentence or two, and keep the two cards about the same length.
12
+ - Never promise automatic anonymization. The contributor's honest answer decides mystery or direct reveal.
@@ -0,0 +1,30 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * The card a voice recorder lives in, drawing one step at a time: a prompt and Record, a blinking dot with the time and Stop, or the take to play back with Redo and Use this. It only shows a state; AudioRecorder does the recording.
5
+ * @startingPoint section="Contributor" subtitle="Voice-recorder card: idle, recording, review" viewport="700x300"
6
+ */
7
+ export interface RecorderPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
8
+ phase?: 'idle' | 'recording' | 'review';
9
+ /** False when the browser can't record, which shows a note to upload a file instead. Default true. */
10
+ supported?: boolean;
11
+ /** The line above Record. Default "Record a voice note." */
12
+ prompt?: React.ReactNode;
13
+ /** Time recorded so far, in seconds. */
14
+ seconds?: number;
15
+ /** The limit shown beside the time, in seconds. Default 600. */
16
+ maxSeconds?: number;
17
+ /** The recorded take to play back in the review step. */
18
+ url?: string | null;
19
+ /** A problem to show under the step, such as a blocked microphone. */
20
+ error?: string | null;
21
+ onRecord?: () => void;
22
+ onStop?: () => void;
23
+ onRedo?: () => void;
24
+ onUse?: () => void;
25
+ onCancel?: () => void;
26
+ }
27
+
28
+ export function RecorderPanel(props: RecorderPanelProps): React.JSX.Element;
29
+ /** 90 gives "1:30". Returns null for a missing or non-finite number. */
30
+ export function formatDuration(seconds?: number | null): string | null;
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { Button } from '../buttons/Button';
4
+
5
+ // 90 seconds is "1:30"
6
+ export function formatDuration(seconds) {
7
+ if (seconds == null || !Number.isFinite(seconds)) return null;
8
+ const s = Math.round(seconds);
9
+ return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
10
+ }
11
+
12
+ /* The card a voice recorder lives in. It only draws one step at a time: unsupported, idle (a prompt and Record), recording
13
+ (a blinking dot, the time and Stop) or review (the take to play back, Redo and Use this). The recording itself is
14
+ AudioRecorder's job; this panel is for showing a state or wiring your own recorder. */
15
+ export function RecorderPanel({ phase = 'idle', supported = true, prompt = 'Record a voice note.', seconds = 0, maxSeconds = 600, url, error, onRecord, onStop, onRedo, onUse, onCancel, className, ...rest }) {
16
+ return (
17
+ <div className={['mp-recorder', className].filter(Boolean).join(' ')} {...rest}>
18
+ {!supported ? (
19
+ <p className="mp-recorder-lede">This browser can&apos;t record audio. Upload an audio file instead.</p>
20
+ ) : phase === 'idle' ? (
21
+ <>
22
+ <p className="mp-recorder-lede">{prompt}</p>
23
+ <Button size="lg" variant="danger" onClick={onRecord} icon={<i className="ph-fill ph-microphone" aria-hidden="true" />}>Record</Button>
24
+ </>
25
+ ) : phase === 'recording' ? (
26
+ <>
27
+ <div className="mp-recorder-row" role="status" aria-live="polite">
28
+ <span className="mp-rec-dot" aria-hidden="true" />
29
+ <strong style={{ fontSize: 22, fontVariantNumeric: 'tabular-nums' }}>{formatDuration(seconds)}</strong>
30
+ <span className="mp-recorder-small">/ {formatDuration(maxSeconds)}</span>
31
+ </div>
32
+ <Button size="lg" onClick={onStop} icon={<i className="ph-fill ph-stop" aria-hidden="true" />}>Stop</Button>
33
+ </>
34
+ ) : (
35
+ url && (
36
+ <>
37
+ <audio controls src={url} style={{ width: '100%' }} />
38
+ <div className="mp-recorder-row">
39
+ <Button variant="ghost" onClick={onRedo}>Redo</Button>
40
+ <Button onClick={onUse}>Use this</Button>
41
+ </div>
42
+ </>
43
+ )
44
+ )}
45
+ {error && <p className="mp-recorder-small" style={{ color: 'var(--coral-700)', fontWeight: 700 }}>{error}</p>}
46
+ {phase !== 'recording' && <button type="button" className="mp-link-button" onClick={onCancel}>Cancel</button>}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,11 @@
1
+ Recorder panel: the card a voice recorder lives in, drawing one step at a time.
2
+
3
+ ```jsx
4
+ <RecorderPanel phase="idle" prompt="Record a voice note." onRecord={start} onCancel={close} />
5
+ <RecorderPanel phase="recording" seconds={42} onStop={stop} />
6
+ <RecorderPanel phase="review" url={takeUrl} onRedo={redo} onUse={use} onCancel={close} />
7
+ ```
8
+
9
+ - It only draws a state. For a working recorder that asks for the microphone, use `AudioRecorder`, which is this panel plus the recording.
10
+ - Cancel shows in the idle and review steps, but not while recording, so a take isn't thrown away by a stray tap.
11
+ - Set `supported={false}` where the browser has no recorder, to point people to uploading a file instead.
@@ -0,0 +1,17 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Contributor-flow progress: numbered step markers on a short stretch of road that fills green as steps finish. The current marker pulses, and a step forward celebrates.
5
+ * @startingPoint section="Contributor" subtitle="Flow steps, with and without labels" viewport="700x200"
6
+ */
7
+ export interface StepBarProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style'> {
8
+ /** Step names, in order. Without `labels` they're only read out to screen readers. */
9
+ steps: string[];
10
+ /** 0-based current step. */
11
+ current?: number;
12
+ /** Shows each step's name under its marker, on wider 68px slots. Without it, slots are 46px. */
13
+ labels?: boolean;
14
+ style?: React.CSSProperties;
15
+ }
16
+
17
+ export function StepBar(props: StepBarProps): React.JSX.Element;
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+ import React, { useEffect, useLayoutEffect, useState } from 'react';
3
+
4
+ // Where each trail was last seen in this tab, so only a forward step celebrates.
5
+ const seen = {};
6
+ // Decided in the browser, before paint. A server can't know what this tab saw, and an object shared
7
+ // by every request would leak one visitor's steps into someone else's page.
8
+ const useBrowserEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
9
+
10
+ export function StepBar({ steps = [], current = 0, labels = false, style, ...rest }) {
11
+ const slot = labels ? 68 : 46;
12
+ const kind = steps.join('|');
13
+ const [advanced, setAdvanced] = useState(false);
14
+ // Once per mount: did they just finish the step before this one?
15
+ useBrowserEffect(() => {
16
+ const prev = seen[kind];
17
+ if (prev !== undefined && current === prev + 1) setAdvanced(true);
18
+ }, []);
19
+ // Remember every step it shows, not only the first, so a bar that moved while mounted isn't misread later.
20
+ useBrowserEffect(() => { seen[kind] = current; }, [kind, current]);
21
+ const n = steps.length;
22
+ const filled = Math.max(0, Math.min(current, n - 1)); // all done fills the road, never past its end
23
+ return (
24
+ <nav aria-label="Progress" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 6, boxSizing: 'border-box', ...style }} {...rest}>
25
+ <div style={{ position: 'relative' }}>
26
+ <span aria-hidden="true" style={{ position: 'absolute', top: 12, left: slot / 2, right: slot / 2, height: 12, borderRadius: 999,
27
+ background: 'var(--world-path)', boxShadow: '0 0 0 3px var(--world-path-edge), 0 4px 0 3px oklch(0.25 0.05 60 / 0.2)' }} />
28
+ <span aria-hidden="true" style={{ position: 'absolute', top: 16, left: slot / 2, width: slot * filled, height: 4, borderRadius: 999,
29
+ background: 'var(--green-500)', transition: 'width var(--dur-slow) var(--ease-out)',
30
+ ...(advanced ? { '--from': slot * Math.min(current - 1, filled) + 'px', '--to': slot * filled + 'px', animation: 'mp-trail-grow var(--dur-slow) var(--ease-out) 260ms both' } : null) }} />
31
+ <ol style={{ position: 'relative', listStyle: 'none', margin: 0, padding: 0, display: 'flex' }}>
32
+ {steps.map((label, i) => {
33
+ const done = i < current, cur = i === current, justDone = advanced && i === current - 1;
34
+ return (
35
+ <li key={i} aria-current={cur ? 'step' : undefined} style={{ width: slot, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
36
+ <span aria-hidden="true" className={'mp-step' + (done ? ' mp-step-done' : cur ? ' mp-step-current' : '')}
37
+ style={justDone ? { animation: 'mp-pop var(--dur-slow) var(--ease-bounce) 560ms both' } : undefined}>
38
+ {done ? <i className="ph-bold ph-check" /> : i + 1}
39
+ {cur && <span style={{ position: 'absolute', inset: -4, borderRadius: '50%', animation: 'mp-pulse-ring 1.6s ease-out infinite' }} />}
40
+ </span>
41
+ {labels ? (
42
+ <span style={{ fontFamily: 'var(--font-display)', letterSpacing: '0.04em', textTransform: 'uppercase', color: '#fff', WebkitTextStroke: '0.22em var(--world-stroke)', paintOrder: 'stroke fill', whiteSpace: 'nowrap', fontSize: 11 }}>
43
+ {label}{done && <span className="mp-sr">, done</span>}
44
+ </span>
45
+ ) : (
46
+ <span className="mp-sr">{'Step ' + (i + 1) + ' of ' + n + ': ' + label}{done && <span className="mp-sr">, done</span>}</span>
47
+ )}
48
+ </li>
49
+ );
50
+ })}
51
+ </ol>
52
+ </div>
53
+ </nav>
54
+ );
55
+ }