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,26 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Contributor portrait with a white ring; falls back to a deterministic generated avatar (hue + pattern from seed, initials); mystery mode shows a neutral "?" and leaks nothing.
5
+ * @startingPoint section="Identity" subtitle="Portrait, generated fallback, mystery" viewport="700x140"
6
+ */
7
+ export interface AvatarProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'> {
8
+ /** Display name (initials + accessible label). */
9
+ name?: string;
10
+ /** Stable id used to pick the generated look (falls back to name). Use the contributor id so the avatar never changes. */
11
+ seed?: string;
12
+ /** Portrait URL. When present, replaces the generated avatar. */
13
+ src?: string;
14
+ /** Diameter in px. Default 64. */
15
+ size?: number;
16
+ /** Neutral unknown-person avatar. Ignores name/seed/src entirely so nothing identifying can leak. */
17
+ mystery?: boolean;
18
+ /** White ring (default true). */
19
+ ring?: boolean;
20
+ alt?: string;
21
+ style?: React.CSSProperties;
22
+ }
23
+
24
+ export function Avatar(props: AvatarProps): React.JSX.Element;
25
+ /** First letters of up to two words. */
26
+ export function initials(name?: string): string;
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ import React from 'react';
3
+
4
+ function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
5
+ const HUES = [25, 55, 95, 140, 175, 215, 260, 300, 335];
6
+ const PATTERNS = ['dots', 'stripes', 'rings', 'plain'];
7
+ // Letters only, with notes in brackets dropped, so "Bayo (work)" gives "B" rather than "B(".
8
+ export function initials(name = '') { return String(name).replace(/\(.*?\)/g, ' ').split(/\s+/).map(w => w.replace(/[^\p{L}\p{N}]/gu, '')).filter(Boolean).slice(0, 2).map(w => [...w][0].toUpperCase()).join(''); } // [...w] keeps letters outside the BMP whole
9
+
10
+ export function Avatar({ name = '', seed, src, size = 64, mystery = false, ring = true, alt, style, ...rest }) {
11
+ const b = Math.max(2, Math.round(size * 0.05));
12
+ const base = { position: 'relative', width: size, height: size, borderRadius: '50%', flex: 'none', overflow: 'hidden', boxSizing: 'border-box', border: ring ? b + 'px solid #fff' : 0,
13
+ boxShadow: '0 ' + b + 'px 0 oklch(0 0 0 / 0.18), 0 4px 10px oklch(0.2 0.05 60 / 0.25)', display: 'grid', placeItems: 'center', ...style };
14
+ if (mystery) return (
15
+ <span role="img" aria-label="Unknown person" style={{ ...base, background: 'radial-gradient(circle at 50% 30%, var(--stone-100), var(--stone-300) 70%)' }} {...rest}>
16
+ <span aria-hidden="true" style={{ fontFamily: 'var(--font-display)', fontSize: size * 0.5, color: '#fff', WebkitTextStroke: (size * 0.06) + 'px var(--stone-700)', paintOrder: 'stroke fill', lineHeight: 1 }}>?</span>
17
+ </span>
18
+ );
19
+ if (src) return <span style={base} {...rest}><img src={src} alt={alt || name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /></span>;
20
+ const h = hash(String(seed || name || 'anon')), hue = HUES[h % HUES.length], pat = PATTERNS[(h >> 4) % PATTERNS.length];
21
+ const light = 'oklch(0.86 0.11 ' + hue + ')', mid = 'oklch(0.68 0.16 ' + hue + ')', deep = 'oklch(0.42 0.12 ' + hue + ')';
22
+ const cell = Math.round(size * 0.22), band = Math.round(size * 0.08);
23
+ const overlay = pat === 'dots' ? 'radial-gradient(oklch(1 0 0 / 0.35) 18%, transparent 21%) 0 0 / ' + cell + 'px ' + cell + 'px'
24
+ : pat === 'stripes' ? 'repeating-linear-gradient(-45deg, oklch(1 0 0 / 0.22) 0 ' + band + 'px, transparent ' + band + 'px ' + (band * 2.5) + 'px)'
25
+ : pat === 'rings' ? 'repeating-radial-gradient(circle at 50% 120%, oklch(1 0 0 / 0.25) 0 ' + band + 'px, transparent ' + band + 'px ' + (band * 2.5) + 'px)' : '';
26
+ return (
27
+ <span role="img" aria-label={alt || name || 'Contributor'} style={{ ...base, background: (overlay ? overlay + ', ' : '') + 'linear-gradient(160deg, ' + light + ', ' + mid + ' 60%, ' + deep + ')' }} {...rest}>
28
+ <span aria-hidden="true" style={{ fontFamily: 'var(--font-display)', fontSize: size * 0.42, color: '#fff', WebkitTextStroke: (size * 0.05) + 'px ' + deep, paintOrder: 'stroke fill', lineHeight: 1, letterSpacing: '0.02em' }}>{initials(name) || '•'}</span>
29
+ </span>
30
+ );
31
+ }
@@ -0,0 +1,11 @@
1
+ Avatar: every person in the adventure. Portrait if provided; otherwise a generated avatar (never a grey silhouette); mystery when unrevealed.
2
+
3
+ ```jsx
4
+ <Avatar name="Ada Okafor" seed="c_01" size={96} /> // generated: hue + pattern from seed, initials AO
5
+ <Avatar name="Ada Okafor" src={portraitUrl} size={96} /> // real portrait
6
+ <Avatar mystery size={96} /> // unrevealed: no name, no seed, no src
7
+ ```
8
+
9
+ - Pass the contributor id as seed so the generated look is stable across screens and sessions.
10
+ - For unrevealed people render mystery and do NOT pass name/seed/src, the component ignores them, but the payload must not contain them either.
11
+ - Portraits are optional and never a prerequisite for guessing.
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * The person's own avatar as a 48px button with a small pencil badge, for opening the page where they change their name and photo.
5
+ * @startingPoint section="Identity" subtitle="Own avatar with a pencil badge" viewport="700x120"
6
+ */
7
+ export interface AvatarButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
8
+ /** Display name, for the generated avatar's initials. */
9
+ name?: string;
10
+ /** Stable id for the generated look. */
11
+ seed?: string;
12
+ /** Portrait URL. */
13
+ src?: string;
14
+ /** Accessible name. Default "Edit your name and photo". */
15
+ label?: string;
16
+ onClick?: () => void;
17
+ }
18
+
19
+ export function AvatarButton(props: AvatarButtonProps): React.JSX.Element;
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { Avatar } from './Avatar';
4
+
5
+ /* The person's own avatar as a button, with a small pencil to say it can be changed. Fixed at 48px so it sits in a HUD row. */
6
+ export function AvatarButton({ name, seed, src, label = 'Edit your name and photo', onClick, className, ...rest }) {
7
+ return (
8
+ <button type="button" className={['mp-avatar-button', className].filter(Boolean).join(' ')} onClick={onClick} aria-label={label} {...rest}>
9
+ <Avatar name={name} alt="" seed={seed} src={src} size={48} />
10
+ <span className="mp-avatar-badge" aria-hidden="true"><i className="ph-fill ph-pencil-simple" /></span>
11
+ </button>
12
+ );
13
+ }
@@ -0,0 +1,8 @@
1
+ Avatar button: the person's own face, pressed to edit their profile.
2
+
3
+ ```jsx
4
+ <AvatarButton name={me.displayName} seed={me.id} src={portraitUrl} onClick={() => go('you')} />
5
+ ```
6
+
7
+ - Use it only for the person's own avatar, where pressing it changes something. Anyone else's portrait is a plain `Avatar`.
8
+ - The label says what pressing does, not who it is.
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Quiz gemstone: colour + distinct cut per gem (amber hexagon, emerald octagon, amethyst kite, pearl round), tier I plain / II sparkle / III halo.
5
+ * @startingPoint section="Identity" subtitle="Amber / Emerald / Amethyst × tiers, Pearl keepsake" viewport="700x220"
6
+ */
7
+ export interface GemstoneProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'> {
8
+ /** amber = score 0–2, emerald = 3–5, amethyst = 6–8, pearl = quiz skipped (unranked keepsake). */
9
+ gem?: 'amber' | 'emerald' | 'amethyst' | 'pearl';
10
+ /** 1 plain · 2 sparkle · 3 halo. Ignored for pearl. */
11
+ tier?: 1 | 2 | 3;
12
+ /** Box size in px. Default 64. Halo extends ~20% beyond. */
13
+ size?: number;
14
+ /** Accessible name override (default "Emerald, tier II" / "Pearl keepsake"). */
15
+ label?: string;
16
+ /** Visual only, with no visible or accessible label. Defaults to !showTier. Use true on the recipient’s surfaces. */
17
+ decorative?: boolean;
18
+ /** Prints "Emerald II" / "Keepsake" under the stone: FRIEND-FACING ONLY; never on the recipient’s reveal card or collection. */
19
+ showTier?: boolean;
20
+ /** Sparkle/halo animation (default true; static in dense lists). */
21
+ animate?: boolean;
22
+ style?: React.CSSProperties;
23
+ }
24
+
25
+ export function Gemstone(props: GemstoneProps): React.JSX.Element;
26
+ /** Maps a 0–8 quiz score (or null for skipped) to { gem, tier }. */
27
+ export function gemForScore(score: number | null | undefined): { gem: 'amber' | 'emerald' | 'amethyst' | 'pearl'; tier: 0 | 1 | 2 | 3 };
28
+ export const GEM_NAMES: Record<'amber' | 'emerald' | 'amethyst' | 'pearl', string>;
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+ import React from 'react';
3
+
4
+ const GEMS = {
5
+ amber: { name: 'Amber', light: 'var(--gem-amber-light)', fill: 'var(--gem-amber)', deep: 'var(--gem-amber-deep)', clip: 'polygon(50% 0%, 96% 24%, 96% 76%, 50% 100%, 4% 76%, 4% 24%)', shape: 'hexagon' },
6
+ emerald: { name: 'Emerald', light: 'var(--gem-emerald-light)', fill: 'var(--gem-emerald)', deep: 'var(--gem-emerald-deep)', clip: 'polygon(26% 4%, 74% 4%, 96% 26%, 96% 74%, 74% 96%, 26% 96%, 4% 74%, 4% 26%)', shape: 'octagon' },
7
+ amethyst: { name: 'Amethyst', light: 'var(--gem-amethyst-light)', fill: 'var(--gem-amethyst)', deep: 'var(--gem-amethyst-deep)', clip: 'polygon(50% 0%, 96% 38%, 50% 100%, 4% 38%)', shape: 'kite' },
8
+ pearl: { name: 'Pearl', light: 'var(--gem-pearl-light)', fill: 'var(--gem-pearl)', deep: 'var(--gem-pearl-deep)', clip: 'circle(48% at 50% 50%)', shape: 'round' },
9
+ };
10
+ const ROMAN = ['', 'I', 'II', 'III'];
11
+ export const GEM_NAMES = { amber: 'Amber', emerald: 'Emerald', amethyst: 'Amethyst', pearl: 'Pearl' };
12
+
13
+ // Score → gem + tier. null/undefined (quiz skipped) → pearl keepsake, tier 0 (unranked, not a zero).
14
+ export function gemForScore(score) {
15
+ if (score == null) return { gem: 'pearl', tier: 0 };
16
+ const s = Math.max(0, Math.min(8, Math.round(score)));
17
+ return { gem: s < 3 ? 'amber' : s < 6 ? 'emerald' : 'amethyst', tier: (s % 3) + 1 };
18
+ }
19
+
20
+ export function Gemstone({ gem = 'amber', tier = 1, size = 64, label, showTier = false, decorative = !showTier, animate = true, style, ...rest }) {
21
+ const g = GEMS[gem] || GEMS.amber;
22
+ const pearl = gem === 'pearl';
23
+ const t = pearl ? 0 : Math.max(1, Math.min(3, tier));
24
+ const sparkle = t >= 2, halo = t === 3;
25
+ const inset = Math.max(2, Math.round(size * 0.07));
26
+ const aria = label || (pearl ? 'Pearl keepsake' : g.name + ', tier ' + ROMAN[t]);
27
+ const layer = (bg, extra) => <span aria-hidden="true" style={{ position: 'absolute', inset, clipPath: g.clip, background: bg, ...extra }} />;
28
+ return (
29
+ <span role={decorative ? undefined : "img"} aria-hidden={decorative || undefined} aria-label={decorative ? undefined : aria} style={{ position: 'relative', display: 'inline-grid', justifyItems: 'center', width: size, flex: 'none', ...style }} {...rest}>
30
+ <span style={{ position: 'relative', width: size, height: size, filter: 'drop-shadow(0 ' + Math.max(2, Math.round(size * 0.05)) + 'px 0 oklch(0 0 0 / 0.22))' }}>
31
+ {halo && <span aria-hidden="true" style={{ position: 'absolute', inset: '-22%', borderRadius: '50%', background: 'radial-gradient(circle, ' + g.light + ' 0%, ' + g.fill + ' 30%, transparent 70%)', opacity: 0.8, animation: animate ? 'mp-float var(--dur-idle) ease-in-out infinite' : 'none' }} />}
32
+ {halo && <span aria-hidden="true" style={{ position: 'absolute', inset: '-10%', borderRadius: '50%', border: Math.max(2, Math.round(size * 0.045)) + 'px solid #fff', opacity: 0.9 }} />}
33
+ <span aria-hidden="true" style={{ position: 'absolute', inset: 0, clipPath: g.clip, background: g.deep }} />
34
+ {layer('linear-gradient(155deg, ' + g.light + ' 0%, ' + g.fill + ' 48%, ' + g.deep + ' 100%)')}
35
+ {layer('linear-gradient(180deg, oklch(1 0 0 / 0.6), oklch(1 0 0 / 0) 60%)', { clipPath: pearl ? g.clip : 'polygon(50% 12%, 78% 34%, 50% 54%, 22% 34%)' })}
36
+ {!pearl && layer('linear-gradient(0deg, oklch(0 0 0 / 0.28), transparent 42%)')}
37
+ {pearl && <span aria-hidden="true" style={{ position: 'absolute', left: '24%', top: '18%', width: '26%', height: '15%', borderRadius: '50%', background: 'oklch(1 0 0 / 0.9)' }} />}
38
+ {sparkle && [[-0.1, -0.06, 0], [0.64, 0.66, 600]].map(([x, y, d], i) => (
39
+ <i key={i} aria-hidden="true" className="ph-fill ph-sparkle" style={{ position: 'absolute', left: x * size, top: y * size, fontSize: size * 0.36, lineHeight: 1, color: '#fff', WebkitTextStroke: (size * 0.03) + 'px ' + g.deep, paintOrder: 'stroke fill', animation: animate ? 'mp-sparkle 1.8s ease-in-out infinite ' + d + 'ms' : 'none' }} />
40
+ ))}
41
+ </span>
42
+ {showTier && !decorative && !pearl && <span style={{ marginTop: 6, fontFamily: 'var(--font-display)', fontSize: Math.max(11, Math.round(size * 0.2)), color: 'var(--ink-700)', letterSpacing: '0.08em' }}>{g.name + ' ' + ROMAN[t]}</span>}
43
+ {showTier && !decorative && pearl && <span style={{ marginTop: 6, fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: Math.max(11, Math.round(size * 0.17)), color: 'var(--ink-500)' }}>Keepsake</span>}
44
+ </span>
45
+ );
46
+ }
@@ -0,0 +1,15 @@
1
+ Quiz gemstone: the only visual for quiz results. Colour AND cut differ per gem so it never relies on colour alone.
2
+
3
+ ```jsx
4
+ const { gem, tier } = gemForScore(7); // → amethyst II
5
+ <Gemstone gem={gem} tier={tier} size={72} />
6
+ <Gemstone gem="amber" tier={1} size={40} animate={false} /> // dense lists
7
+ <Gemstone gem="pearl" size={56} /> // quiz skipped: keepsake, unranked
8
+ <Gemstone gem="emerald" tier={3} showTier /> // friend-facing result / leaderboard only
9
+ ```
10
+
11
+ - The recipient’s surfaces (reveal card, Your People) show the stone only, never score, rank or tier text. Friends' surfaces may add showTier and the score.
12
+ - Tier III = halo (+ sparkle). Pearl has no tiers and is never ranked or shown as a zero.
13
+ - Size ≥ 28 in lists, 56–72 on cards. Tap-to-explain is allowed: wrap in a button with aria-label "About this gem".
14
+
15
+ Project update: The recipient’s gems use decorative={true}: no labels, including accessibility labels. Default decorative is !showTier; contributor-facing showTier still exposes labels. See PROJECT-DECISIONS.md.
@@ -0,0 +1,22 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * "Your People" collection tile: revealed (avatar + name + gem badge), mystery (neutral, nothing identifying) or new (late arrival awaiting discovery).
5
+ * @startingPoint section="Identity" subtitle="Revealed / mystery / new collection tiles" viewport="700x200"
6
+ */
7
+ export interface PersonTileProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style'> {
8
+ /** mystery renders no name, portrait or gem regardless of the other props. */
9
+ state?: 'revealed' | 'mystery' | 'new';
10
+ name?: string;
11
+ relationship?: string;
12
+ portrait?: string;
13
+ seed?: string;
14
+ gem?: 'amber' | 'emerald' | 'amethyst' | 'pearl';
15
+ tier?: 1 | 2 | 3;
16
+ /** Default 116. */
17
+ width?: number;
18
+ onClick?: () => void;
19
+ style?: React.CSSProperties;
20
+ }
21
+
22
+ export function PersonTile(props: PersonTileProps): React.JSX.Element;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+ import { Avatar } from './Avatar';
4
+ import { Gemstone } from './Gemstone';
5
+
6
+ export function PersonTile({ state = 'mystery', name, relationship, portrait, seed, gem = 'amber', tier = 1, width = 116, onClick, style, ...rest }) {
7
+ const [down, setDown] = useState(false);
8
+ const mystery = state === 'mystery', isNew = state === 'new';
9
+ const Tag = onClick ? 'button' : 'div';
10
+ return (
11
+ <Tag type={onClick ? 'button' : undefined} onClick={onClick} aria-label={mystery ? 'Not yet found' : name + (isNew ? ', new surprise' : '')}
12
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
13
+ style={{ appearance: 'none', margin: 0, position: 'relative', width, padding: '16px 8px 12px', boxSizing: 'border-box', display: 'grid', justifyItems: 'center', gap: 8, borderRadius: 'var(--radius-lg)',
14
+ border: '3px solid ' + (mystery ? 'oklch(1 0 0 / 0.3)' : isNew ? 'var(--gold-300)' : 'var(--cream-300)'),
15
+ background: mystery ? 'oklch(0.34 0.09 145 / 0.30)' : 'linear-gradient(180deg, #fff, var(--cream-100))',
16
+ boxShadow: mystery ? 'inset 0 2px 6px oklch(0 0 0 / 0.25)' : '0 ' + (down && onClick ? 1 : 4) + 'px 0 ' + (isNew ? 'var(--gold-500)' : 'var(--cream-300)') + ', 0 6px 10px oklch(0.2 0.05 60 / 0.2)',
17
+ transform: down && onClick && !mystery ? 'translateY(3px)' : 'none', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)', cursor: onClick ? 'pointer' : 'default', fontFamily: 'var(--font-ui)', textAlign: 'center', WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
18
+ {isNew && <span style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', height: 20, padding: '0 8px', borderRadius: 6, background: 'var(--gold-500)', boxShadow: '0 2px 0 var(--gold-700)', fontFamily: 'var(--font-display)', fontSize: 11, letterSpacing: '0.06em', color: 'var(--gold-900)', display: 'grid', placeItems: 'center', whiteSpace: 'nowrap' }}>NEW</span>}
19
+ <span style={{ position: 'relative' }}>
20
+ <Avatar mystery={mystery} name={mystery ? '' : name} seed={mystery ? undefined : seed} src={mystery ? undefined : portrait} size={60} />
21
+ {!mystery && <Gemstone gem={gem} tier={tier} size={30} decorative animate={false} style={{ position: 'absolute', right: -12, bottom: -6 }} />}
22
+ </span>
23
+ <span style={{ fontWeight: 700, fontSize: 13, lineHeight: 1.2, color: mystery ? '#fff' : 'var(--ink-900)', textShadow: mystery ? '0 1px 0 oklch(0 0 0 / 0.4)' : 'none' }}>{mystery ? 'Not yet found' : name}</span>
24
+ {!mystery && relationship && <span style={{ fontWeight: 600, fontSize: 11, color: 'var(--ink-500)', marginTop: -6 }}>{relationship}</span>}
25
+ </Tag>
26
+ );
27
+ }
@@ -0,0 +1,10 @@
1
+ Person tile: cells of the recipient’s "Your People" collection. Order ranked people by quiz result but emphasise the gem; show no score text.
2
+
3
+ ```jsx
4
+ <PersonTile state="revealed" name="Ada Okafor" relationship="Sister" seed="c_01" gem="amethyst" tier={3} onClick={open} />
5
+ <PersonTile state="mystery" />
6
+ <PersonTile state="new" name="Tunde B." seed="c_22" gem="amber" tier={1} onClick={openDiscovery} />
7
+ ```
8
+
9
+ - Grid: 3 per row on phones, 5–6 on iPad, 10px gaps, over the world background.
10
+ - Mystery tiles get no identifying props at all. Late arrivals stay "new" until their discovery reveal.
@@ -0,0 +1,20 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Full-width answer option for the sender guess and the friends' quiz. Letter badge + result glyph make states colour-independent.
5
+ * @startingPoint section="Levels" subtitle="Guess / quiz option: idle, selected, correct, wrong, dim" viewport="700x330"
6
+ */
7
+ export interface ChoiceButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
8
+ /** 0 → A, 1 → B, 2 → C … */
9
+ index?: number;
10
+ /** idle · selected (blue) · correct (green + check) · wrong (coral + ×, shakes once) · dim (stone, after resolution). */
11
+ state?: 'idle' | 'selected' | 'correct' | 'wrong' | 'dim';
12
+ /** md 56px (phone) · lg 64px (iPad). */
13
+ size?: 'md' | 'lg';
14
+ children?: React.ReactNode;
15
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
16
+ disabled?: boolean;
17
+ style?: React.CSSProperties;
18
+ }
19
+
20
+ export function ChoiceButton(props: ChoiceButtonProps): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ const TONES = {
5
+ idle: { top: '#fff', mid: 'var(--cream-100)', shade: 'var(--cream-300)', text: 'var(--ink-900)', badge: 'var(--blue-500)', badgeText: '#fff' },
6
+ selected: { top: 'var(--blue-300)', mid: 'var(--blue-500)', shade: 'var(--blue-700)', text: '#fff', badge: '#fff', badgeText: 'var(--blue-700)' },
7
+ correct: { top: 'var(--green-300)', mid: 'var(--green-500)', shade: 'var(--green-700)', text: '#fff', badge: '#fff', badgeText: 'var(--green-700)', glyph: 'check', sr: 'Correct' },
8
+ wrong: { top: 'var(--coral-300)', mid: 'var(--coral-500)', shade: 'var(--coral-700)', text: '#fff', badge: '#fff', badgeText: 'var(--coral-700)', glyph: 'x', sr: 'Not this one' },
9
+ dim: { top: 'var(--stone-100)', mid: 'var(--stone-300)', shade: 'var(--stone-500)', text: 'var(--ink-700)', badge: 'var(--stone-500)', badgeText: '#fff' },
10
+ };
11
+ const SR = { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' };
12
+
13
+ export function ChoiceButton({ index = 0, state = 'idle', size = 'md', children, onClick, disabled = false, style, ...rest }) {
14
+ const [down, setDown] = useState(false);
15
+ const t = TONES[state] || TONES.idle;
16
+ const h = size === 'lg' ? 64 : 56, depth = 5;
17
+ const off = disabled || state === 'dim' || state === 'correct' || state === 'wrong';
18
+ const lift = down && !off ? 1 : depth;
19
+ return (
20
+ <button type="button" onClick={off ? undefined : onClick} disabled={disabled} aria-pressed={state === 'selected'} aria-disabled={off && !disabled ? true : undefined}
21
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
22
+ style={{ appearance: 'none', border: 0, margin: 0, width: '100%', minHeight: h, padding: '8px 14px 8px 10px', boxSizing: 'border-box', display: 'flex', alignItems: 'center', gap: 14, borderRadius: 'var(--radius-md)', textAlign: 'left', cursor: off ? 'default' : 'pointer',
23
+ background: 'linear-gradient(180deg, ' + t.top + ' 0%, ' + t.mid + ' 42%)', boxShadow: '0 ' + lift + 'px 0 ' + t.shade + ', 0 ' + (lift + 5) + 'px 12px oklch(0.2 0.04 60 / 0.2), inset 0 2px 0 oklch(1 0 0 / 0.45)' + (state === 'correct' ? ', 0 0 0 4px oklch(0.92 0.14 90 / 0.65), 0 0 22px oklch(0.92 0.14 90 / 0.8)' : ''),
24
+ transform: 'translateY(' + (depth - lift) + 'px)', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)', fontFamily: 'var(--font-ui)', fontWeight: 700, fontSize: size === 'lg' ? 20 : 18, color: t.text,
25
+ textShadow: t.text === '#fff' ? '0 2px 0 ' + t.shade : 'none', opacity: state === 'dim' ? 0.7 : 1, WebkitTapHighlightColor: 'transparent', animation: state === 'wrong' ? 'mp-shake 0.4s var(--ease-out) 1' : state === 'correct' ? 'mp-pop var(--dur-slow) var(--ease-bounce) 1' : 'none', ...style }} {...rest}>
26
+ <span aria-hidden="true" style={{ width: 34, height: 34, borderRadius: '50%', flex: 'none', display: 'grid', placeItems: 'center', background: t.badge, color: t.badgeText, fontFamily: 'var(--font-display)', fontSize: 16, boxShadow: 'inset 0 -2px 0 oklch(0 0 0 / 0.15)' }}>{String.fromCharCode(65 + index)}</span>
27
+ <span style={{ flex: 1, minWidth: 0, lineHeight: 1.2 }}>{children}</span>
28
+ {t.glyph && <span style={{ width: 30, height: 30, borderRadius: '50%', flex: 'none', display: 'grid', placeItems: 'center', background: '#fff', color: t.badgeText, fontSize: 18 }}><i className={'ph-bold ph-' + t.glyph} aria-hidden="true" /><span style={SR}>{t.sr}</span></span>}
29
+ </button>
30
+ );
31
+ }
@@ -0,0 +1,10 @@
1
+ Choice button: "Who sent this?" options (up to three real names, never invented) and quiz answers. Stack vertically, 10px gaps, one column.
2
+
3
+ ```jsx
4
+ {options.map((o, i) => <ChoiceButton key={o.id} index={i} state={stateFor(o)} onClick={() => pick(o)}>{o.name}</ChoiceButton>)}
5
+ <Button variant="ghost" typeface="ui">Just tell me</Button> // always offered alongside the guess
6
+ ```
7
+
8
+ - Wrong answers: mark that option wrong, keep the others idle for a retry, and keep "Just tell me" available. Never lock the reveal behind a correct guess.
9
+ - After resolution set the correct one to correct and the rest to dim.
10
+ - Use size="lg" on iPad; names are sentence case in the UI face (not display caps).
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Optional memory beside the road: a thematic object on a wooden pedestal (mango, rice bowl, flower, dumbbell, lantern, gift, letter). States: hidden (looks like a bush with a glint), available, new (late arrival), opened.
5
+ * @startingPoint section="Map" subtitle="Discovery markers: hidden / available / new / opened" viewport="700x200"
6
+ */
7
+ export interface DiscoveryMarkerProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
8
+ kind?: 'mango' | 'rice' | 'flower' | 'dumbbell' | 'lantern' | 'gift' | 'letter' | 'star';
9
+ /** hidden = contributor asked for it to be tucked away (a glinting bush); available = glow + float; new = "New surprise" tag; opened = dim + tick. */
10
+ state?: 'hidden' | 'available' | 'new' | 'opened';
11
+ /** Outlined caption under the object. */
12
+ label?: string;
13
+ /** Object size in px. Default 64. */
14
+ size?: number;
15
+ onClick?: () => void;
16
+ style?: React.CSSProperties;
17
+ }
18
+
19
+ export function DiscoveryMarker(props: DiscoveryMarkerProps): React.JSX.Element;
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ const KINDS = { mango: ['orange', 'gold'], rice: ['bowl-food', 'white'], flower: ['flower', 'coral'], dumbbell: ['barbell', 'blue'], lantern: ['lamp-pendant', 'coral'], gift: ['gift', 'berry'], letter: ['envelope', 'gold'], star: ['star', 'gold'] };
5
+ const TONES = { gold: ['var(--gold-500)', 'var(--gold-900)'], white: ['#fff', 'var(--ink-900)'], coral: ['var(--coral-500)', 'var(--coral-900)'], blue: ['var(--blue-500)', 'var(--blue-900)'], berry: ['var(--berry-500)', 'var(--berry-900)'], teal: ['var(--teal-500)', 'var(--teal-900)'] };
6
+
7
+ export function DiscoveryMarker({ kind = 'gift', state = 'available', label, size = 64, onClick, style, ...rest }) {
8
+ const [down, setDown] = useState(false);
9
+ const [glyph, toneKey] = KINDS[kind] || KINDS.gift;
10
+ const [fill, stroke] = TONES[toneKey] || TONES.gold;
11
+ const hidden = state === 'hidden', opened = state === 'opened', isNew = state === 'new', live = state === 'available' || isNew;
12
+ const ped = Math.round(size * 0.42);
13
+ return (
14
+ <button type="button" onClick={onClick} aria-label={label || (hidden ? 'Something in the bushes' : (isNew ? 'New surprise: ' : 'Discovery: ') + kind + (opened ? ', opened' : ''))}
15
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
16
+ style={{ appearance: 'none', background: 'none', border: 0, padding: 0, margin: 0, position: 'relative', display: 'inline-grid', justifyItems: 'center', width: size + 24, cursor: 'pointer', WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
17
+ {isNew && <span style={{ marginBottom: 6, height: 20, padding: '0 8px', borderRadius: 6, background: 'var(--gold-500)', boxShadow: '0 2px 0 var(--gold-700)', fontFamily: 'var(--font-display)', fontSize: 11, letterSpacing: '0.06em', color: 'var(--gold-900)', display: 'grid', placeItems: 'center', whiteSpace: 'nowrap', animation: 'mp-float 1.6s ease-in-out infinite' }}>NEW SURPRISE</span>}
18
+ <span style={{ position: 'relative', width: size, height: Math.round(size * 0.98), display: 'grid', placeItems: 'end center' }}>
19
+ <span aria-hidden="true" style={{ position: 'absolute', left: '6%', right: '6%', bottom: 0, height: ped, borderRadius: '50%',
20
+ background: hidden ? 'radial-gradient(ellipse at 40% 25%, var(--green-300), var(--green-500) 55%, var(--green-700))' : 'radial-gradient(ellipse at 50% 30%, var(--wood-300), var(--wood-500) 70%)',
21
+ boxShadow: '0 ' + (down ? 2 : 5) + 'px 0 ' + (hidden ? 'var(--green-900)' : 'var(--wood-700)') + ', 0 8px 10px oklch(0.2 0.05 60 / 0.3)' + (live ? ', 0 0 0 4px oklch(0.92 0.14 90 / 0.8), 0 0 22px oklch(0.86 0.17 85 / 0.7)' : ''),
22
+ transform: 'translateY(' + (down ? 3 : 0) + 'px)', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)' }} />
23
+ {hidden
24
+ ? <i aria-hidden="true" className="ph-fill ph-sparkle" style={{ position: 'absolute', right: '12%', top: '30%', fontSize: size * 0.28, color: '#fff', filter: 'drop-shadow(0 1px 0 var(--green-900))', animation: 'mp-sparkle 2s ease-in-out infinite' }} />
25
+ : <i aria-hidden="true" className={'ph-fill ph-' + glyph} style={{ position: 'relative', fontSize: size * 0.6, lineHeight: 1, color: fill, WebkitTextStroke: (size * 0.06) + 'px ' + stroke, paintOrder: 'stroke fill', filter: 'drop-shadow(0 3px 0 oklch(0 0 0 / 0.25))', marginBottom: Math.round(ped * 0.45), opacity: opened ? 0.75 : 1, animation: live ? 'mp-float var(--dur-idle) ease-in-out infinite' : 'none' }} />}
26
+ {opened && <span aria-hidden="true" style={{ position: 'absolute', right: -2, top: '10%', width: 22, height: 22, borderRadius: '50%', background: 'var(--green-500)', border: '2px solid #fff', color: '#fff', fontSize: 12, display: 'grid', placeItems: 'center', boxShadow: '0 2px 0 var(--green-700)' }}><i className="ph-bold ph-check" /></span>}
27
+ </span>
28
+ {label && <span style={{ marginTop: 4, fontFamily: 'var(--font-display)', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.04em', color: '#fff', WebkitTextStroke: '0.2em var(--ink-900)', paintOrder: 'stroke fill', textShadow: '0 2px 0 oklch(0 0 0 / 0.25)', whiteSpace: 'nowrap' }}>{label}</span>}
29
+ </button>
30
+ );
31
+ }
@@ -0,0 +1,11 @@
1
+ Discovery marker: extra packages placed beside the road in their world; never on the numbered path and never a completion requirement.
2
+
3
+ ```jsx
4
+ <DiscoveryMarker kind="mango" state="available" onClick={open} />
5
+ <DiscoveryMarker kind="gift" state="new" label="New" onClick={open} /> // arrived after the route was frozen
6
+ <DiscoveryMarker kind="rice" state="hidden" onClick={open} /> // contributor asked to hide it
7
+ <DiscoveryMarker kind="lantern" state="opened" />
8
+ ```
9
+
10
+ - Pick the kind from the world (mango grove → mango; small chops → rice; good energy → dumbbell; japan → lantern/flower). gift for anything else.
11
+ - Keep markers 24px+ off the road so level nodes stay the primary tap.
@@ -0,0 +1,19 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Level node on the world map: a 3D stone/blue disc with an outlined number; stars above when complete, pulse ring when current.
5
+ * @startingPoint section="Map" subtitle="Locked / current / complete level nodes" viewport="700x200"
6
+ */
7
+ export interface LevelNodeProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'style'> {
8
+ number: number | string;
9
+ /** locked = grey stone, unplayable; current = blue with pulse ring; complete = blue with a star row. */
10
+ state?: 'locked' | 'current' | 'complete';
11
+ /** Earned stars (0–3), shown for complete. */
12
+ stars?: number;
13
+ /** Node width in px. Default 84. */
14
+ size?: number;
15
+ onClick?: () => void;
16
+ style?: React.CSSProperties;
17
+ }
18
+
19
+ export function LevelNode(props: LevelNodeProps): React.JSX.Element;
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import React, { useState } from 'react';
3
+
4
+ export function LevelNode({ number, state = 'locked', stars = 0, size = 84, onClick, style, ...rest }) {
5
+ const [down, setDown] = useState(false);
6
+ const locked = state === 'locked';
7
+ const h = Math.round(size * 0.62), depth = Math.round(size * 0.13);
8
+ const top = locked ? 'radial-gradient(ellipse at 50% 30%, var(--stone-100), var(--stone-300) 80%)' : 'radial-gradient(ellipse at 50% 30%, var(--blue-300), var(--blue-500) 80%)';
9
+ const side = locked ? 'var(--stone-500)' : 'var(--blue-700)';
10
+ const rim = locked ? 'var(--stone-300)' : 'var(--blue-300)';
11
+ const stroke = locked ? 'var(--stone-700)' : 'var(--blue-700)';
12
+ const lift = down && !locked ? Math.round(depth * 0.4) : depth;
13
+ const starEls = [];
14
+ if (state === 'complete') for (let i = 0; i < 3; i++) {
15
+ const on = i < stars, s = Math.round(size * 0.3), mid = i === 1;
16
+ starEls.push(<i key={i} className="ph-fill ph-star" aria-hidden="true" style={{ fontSize: s, lineHeight: 1, color: on ? 'var(--gold-500)' : 'var(--stone-300)', WebkitTextStroke: (s * 0.1) + 'px ' + (on ? 'var(--gold-700)' : 'var(--stone-700)'), paintOrder: 'stroke fill',
17
+ transform: 'translateY(' + (mid ? -s * 0.3 : 0) + 'px) rotate(' + ((i - 1) * 16) + 'deg)', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.22))' }} />);
18
+ }
19
+ return (
20
+ <button type="button" onClick={locked ? undefined : onClick} aria-label={'Level ' + number + (locked ? ', locked' : state === 'complete' ? ', ' + stars + ' stars' : ', current')} aria-disabled={locked}
21
+ onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
22
+ style={{ appearance: 'none', background: 'none', border: 0, padding: 0, margin: 0, cursor: locked ? 'default' : 'pointer', position: 'relative', display: 'inline-flex', flexDirection: 'column', alignItems: 'center', width: size + 8, WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
23
+ <span aria-hidden="true" style={{ display: 'flex', alignItems: 'flex-end', height: Math.round(size * 0.36), marginBottom: -Math.round(size * 0.02), gap: 0, zIndex: 2 }}>{starEls}</span>
24
+ <span style={{ position: 'relative', width: size, height: h, marginTop: 0 }}>
25
+ {state === 'current' && <span aria-hidden="true" style={{ position: 'absolute', inset: -2, borderRadius: '50%', animation: 'mp-pulse-ring 1.6s ease-out infinite' }} />}
26
+ <span aria-hidden="true" style={{ position: 'absolute', inset: 0, borderRadius: '50%', background: top, border: '3px solid ' + rim,
27
+ boxShadow: '0 ' + lift + 'px 0 ' + side + ', 0 ' + (lift + 6) + 'px 12px oklch(0.2 0.05 60 / 0.35), inset 0 3px 0 oklch(1 0 0 / 0.55)',
28
+ transform: 'translateY(' + (depth - lift) + 'px)', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)', boxSizing: 'border-box' }}>
29
+ <span style={{ position: 'absolute', left: '18%', top: '12%', width: '28%', height: '18%', borderRadius: '50%', background: 'oklch(1 0 0 / 0.45)' }} />
30
+ <span style={{ position: 'absolute', right: '20%', bottom: '22%', width: '10%', height: '8%', borderRadius: '50%', background: 'oklch(1 0 0 / 0.35)' }} />
31
+ </span>
32
+ <span style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', transform: 'translateY(' + (depth - lift - depth * 0.35) + 'px)', transition: 'transform var(--dur-fast)',
33
+ fontFamily: 'var(--font-display)', fontSize: Math.round(size * 0.46), lineHeight: 1, color: '#fff', WebkitTextStroke: Math.round(size * 0.07) + 'px ' + stroke, paintOrder: 'stroke fill', textShadow: '0 3px 0 oklch(0 0 0 / 0.2)' }}>{number}</span>
34
+ </span>
35
+ </button>
36
+ );
37
+ }
@@ -0,0 +1,11 @@
1
+ Level node: the nodes along the winding path on a world map. Place them absolutely on the map; the path is drawn by the map layer.
2
+
3
+ ```jsx
4
+ <LevelNode number={1} state="complete" stars={3} onClick={() => open(1)} />
5
+ <LevelNode number={4} state="current" onClick={() => open(4)} />
6
+ <LevelNode number={5} state="locked" />
7
+ <LevelNode number={9} size={64} state="locked" />
8
+ ```
9
+
10
+ - Only one current node per map. Locked nodes ignore taps.
11
+ - Keep ≥ 24px of grass between nodes; alternate left/right along the path.
@@ -0,0 +1,27 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Portrait medallion: a revealed friend on a gold pedestal, with the level number on a plate underneath. Takes the LevelNode's place once a level is complete, and stays tappable for replay.
5
+ * @startingPoint section="Map" subtitle="Completed levels as portrait medallions" viewport="700x220"
6
+ */
7
+ export interface PortraitMedallionProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style' | 'onClick'> {
8
+ /** The revealed friend's name. Used for the generated avatar's initials and the accessible name. */
9
+ name: string;
10
+ /** Contributor id that seeds the generated avatar, so it stays the same everywhere. */
11
+ seed?: string;
12
+ /** Portrait URL. Without one, the generated avatar is used. */
13
+ src?: string;
14
+ /** Level number on the plate underneath. Leave it out for discoveries. */
15
+ number?: number | string;
16
+ /** Medallion width in px. Default 84, the same as LevelNode. */
17
+ size?: number;
18
+ /** Play the arrival animation. Use it once, for the level that was just completed. */
19
+ pop?: boolean;
20
+ /** Makes it a button for replay, with the bevel press. */
21
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
22
+ /** Accessible name. Defaults to "Replay level {number}, {name}" (or "Replay surprise from {name}") when clickable, otherwise "Level {number}, {name}" or the name. */
23
+ label?: string;
24
+ style?: React.CSSProperties;
25
+ }
26
+
27
+ export function PortraitMedallion(props: PortraitMedallionProps): React.JSX.Element;
@@ -0,0 +1,29 @@
1
+ 'use client';
2
+ import React from 'react';
3
+ import { Avatar } from '../identity/Avatar';
4
+
5
+ /* A completed level on the map: the friend on a gold pedestal, with the level number on a plate underneath. Give it
6
+ onClick and it becomes a button that replays the level, with a press, a hover and a keyboard focus ring. The pedestal's
7
+ shadow and the number plate are the same at every size. */
8
+ export function PortraitMedallion({ name = '', seed, src, number, size = 84, pop = false, onClick, label, style, ...rest }) {
9
+ const interactive = typeof onClick === 'function';
10
+ const who = name || 'a friend';
11
+ const accessibleName = label || (interactive
12
+ ? (number != null ? 'Replay level ' + number + ', ' + who : 'Replay surprise from ' + who)
13
+ : (number != null ? 'Level ' + number + ', ' + who : name || 'A friend'));
14
+ const medallion = (
15
+ <span className={'mp-medallion' + (pop ? ' mp-medallion-pop' : '')} style={{ '--m': size + 'px', ...(interactive ? null : style) }}
16
+ {...(interactive ? null : { role: 'img', 'aria-label': accessibleName, ...rest })}>
17
+ <span className="mp-medallion-ring">
18
+ <Avatar name={name} seed={seed} src={src} size={Math.round(size * 0.8)} alt="" />
19
+ </span>
20
+ {number != null && <span className="mp-medallion-number">{number}</span>}
21
+ </span>
22
+ );
23
+ if (!interactive) return medallion;
24
+ return (
25
+ <button type="button" className="mp-medallion-button" aria-label={accessibleName} onClick={onClick} style={style} {...rest}>
26
+ {medallion}
27
+ </button>
28
+ );
29
+ }
@@ -0,0 +1,14 @@
1
+ Portrait medallion: what a completed level becomes on the map. The revealed friend on a gold pedestal, with the level number on a plate underneath. Tap to replay.
2
+
3
+ ```jsx
4
+ <PortraitMedallion name="Ada Okafor" seed="c_01" number={4} onClick={() => replay(4)} />
5
+ <PortraitMedallion name="Ada Okafor" src={portraitUrl} number={4} pop onClick={() => replay(4)} /> // just completed
6
+ <PortraitMedallion name="Tunde Bello" seed="c_22" size={76} onClick={() => replay(discovery)} /> // an opened discovery: no number
7
+ ```
8
+
9
+ - Only for completed levels and opened discoveries. Locked and available levels stay a `LevelNode` and never show who's behind them.
10
+ - Use the friend's portrait when there is one, otherwise their seeded avatar.
11
+ - Keep the replay label readable: it defaults to "Replay level 4, Ada Okafor". Pass `label` to change the wording, never to remove the name.
12
+ - `pop` plays the arrival once. Set it only for the level that was just finished.
13
+ - Same size as the `LevelNode` it replaces (84 by default), so the road doesn't shift.
14
+ - The pedestal's shadow and the number plate are the same at every size, so a small medallion for a discovery has the same bevel as a big one. It is a real button when it has `onClick`, with a hover, a press and a keyboard focus ring.
@@ -0,0 +1,22 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * Gold/grey star row for level results; arc mode raises the middle star (level-complete crown).
5
+ * @startingPoint section="Map" subtitle="0–3 stars, flat or arched" viewport="700x140"
6
+ */
7
+ export interface StarRatingProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'> {
8
+ /** Earned stars. */
9
+ value?: number;
10
+ /** Default 3. */
11
+ max?: number;
12
+ /** Star size in px. Default 28. */
13
+ size?: number;
14
+ /** Middle star larger and raised, outer stars tilted. */
15
+ arc?: boolean;
16
+ /** Pop-in each earned star in sequence. */
17
+ animate?: boolean;
18
+ gap?: number;
19
+ style?: React.CSSProperties;
20
+ }
21
+
22
+ export function StarRating(props: StarRatingProps): React.JSX.Element;