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.
- package/LICENSE +21 -0
- package/components/buttons/Button.d.ts +28 -0
- package/components/buttons/Button.jsx +50 -0
- package/components/buttons/Button.prompt.md +16 -0
- package/components/buttons/IconButton.d.ts +23 -0
- package/components/buttons/IconButton.jsx +53 -0
- package/components/buttons/IconButton.prompt.md +12 -0
- package/components/buttons/SegmentedTabs.d.ts +21 -0
- package/components/buttons/SegmentedTabs.jsx +15 -0
- package/components/buttons/SegmentedTabs.prompt.md +10 -0
- package/components/contributor/AudioRecorder.d.ts +27 -0
- package/components/contributor/AudioRecorder.jsx +79 -0
- package/components/contributor/AudioRecorder.prompt.md +13 -0
- package/components/contributor/RadioCard.d.ts +17 -0
- package/components/contributor/RadioCard.jsx +30 -0
- package/components/contributor/RadioCard.prompt.md +12 -0
- package/components/contributor/RecorderPanel.d.ts +30 -0
- package/components/contributor/RecorderPanel.jsx +49 -0
- package/components/contributor/RecorderPanel.prompt.md +11 -0
- package/components/contributor/StepBar.d.ts +17 -0
- package/components/contributor/StepBar.jsx +55 -0
- package/components/contributor/StepBar.prompt.md +12 -0
- package/components/contributor/TextField.d.ts +25 -0
- package/components/contributor/TextField.jsx +29 -0
- package/components/contributor/TextField.prompt.md +7 -0
- package/components/contributor/UploadTile.d.ts +39 -0
- package/components/contributor/UploadTile.jsx +116 -0
- package/components/contributor/UploadTile.prompt.md +21 -0
- package/components/contributor/WorldChip.d.ts +18 -0
- package/components/contributor/WorldChip.jsx +16 -0
- package/components/contributor/WorldChip.prompt.md +9 -0
- package/components/contributor/WorldOption.d.ts +19 -0
- package/components/contributor/WorldOption.jsx +19 -0
- package/components/contributor/WorldOption.prompt.md +11 -0
- package/components/contributor/WorldSign.d.ts +25 -0
- package/components/contributor/WorldSign.jsx +32 -0
- package/components/contributor/WorldSign.prompt.md +14 -0
- package/components/contributor/WorldSigns.d.ts +13 -0
- package/components/contributor/WorldSigns.jsx +12 -0
- package/components/contributor/WorldSigns.prompt.md +8 -0
- package/components/effects/Confetti.d.ts +14 -0
- package/components/effects/Confetti.jsx +38 -0
- package/components/effects/Confetti.prompt.md +12 -0
- package/components/effects/Sparkles.d.ts +9 -0
- package/components/effects/Sparkles.jsx +13 -0
- package/components/effects/Sparkles.prompt.md +11 -0
- package/components/effects/Sunburst.d.ts +23 -0
- package/components/effects/Sunburst.jsx +26 -0
- package/components/effects/Sunburst.prompt.md +12 -0
- package/components/feedback/Badge.d.ts +17 -0
- package/components/feedback/Badge.jsx +16 -0
- package/components/feedback/Badge.prompt.md +10 -0
- package/components/feedback/ErrorText.d.ts +11 -0
- package/components/feedback/ErrorText.jsx +8 -0
- package/components/feedback/ErrorText.prompt.md +9 -0
- package/components/feedback/LoadingScreen.d.ts +14 -0
- package/components/feedback/LoadingScreen.jsx +24 -0
- package/components/feedback/LoadingScreen.prompt.md +9 -0
- package/components/feedback/Notice.d.ts +15 -0
- package/components/feedback/Notice.jsx +16 -0
- package/components/feedback/Notice.prompt.md +10 -0
- package/components/feedback/SaveAlert.d.ts +15 -0
- package/components/feedback/SaveAlert.jsx +24 -0
- package/components/feedback/SaveAlert.prompt.md +9 -0
- package/components/feedback/SavePill.d.ts +13 -0
- package/components/feedback/SavePill.jsx +29 -0
- package/components/feedback/SavePill.prompt.md +8 -0
- package/components/feedback/SoundToggle.d.ts +16 -0
- package/components/feedback/SoundToggle.jsx +15 -0
- package/components/feedback/SoundToggle.prompt.md +6 -0
- package/components/feedback/Sticker.d.ts +15 -0
- package/components/feedback/Sticker.jsx +12 -0
- package/components/feedback/Sticker.prompt.md +9 -0
- package/components/feedback/Tag.d.ts +15 -0
- package/components/feedback/Tag.jsx +15 -0
- package/components/feedback/Tag.prompt.md +10 -0
- package/components/feedback/Toast.d.ts +21 -0
- package/components/feedback/Toast.jsx +19 -0
- package/components/feedback/Toast.prompt.md +7 -0
- package/components/forms/Slider.d.ts +24 -0
- package/components/forms/Slider.jsx +28 -0
- package/components/forms/Slider.prompt.md +6 -0
- package/components/forms/Switch.d.ts +19 -0
- package/components/forms/Switch.jsx +20 -0
- package/components/forms/Switch.prompt.md +6 -0
- package/components/games/MatchCard.d.ts +18 -0
- package/components/games/MatchCard.jsx +27 -0
- package/components/games/MatchCard.prompt.md +11 -0
- package/components/hud/Countdown.d.ts +33 -0
- package/components/hud/Countdown.jsx +74 -0
- package/components/hud/Countdown.prompt.md +15 -0
- package/components/hud/CurrencyPill.d.ts +24 -0
- package/components/hud/CurrencyPill.jsx +34 -0
- package/components/hud/CurrencyPill.prompt.md +12 -0
- package/components/hud/HudBar.d.ts +18 -0
- package/components/hud/HudBar.jsx +14 -0
- package/components/hud/HudBar.prompt.md +15 -0
- package/components/hud/HudChip.d.ts +25 -0
- package/components/hud/HudChip.jsx +30 -0
- package/components/hud/HudChip.prompt.md +13 -0
- package/components/hud/ProgressBar.d.ts +27 -0
- package/components/hud/ProgressBar.jsx +39 -0
- package/components/hud/ProgressBar.prompt.md +11 -0
- package/components/hud/TimerChip.d.ts +19 -0
- package/components/hud/TimerChip.jsx +17 -0
- package/components/hud/TimerChip.prompt.md +7 -0
- package/components/icons/GameIcon.d.ts +25 -0
- package/components/icons/GameIcon.jsx +43 -0
- package/components/icons/GameIcon.prompt.md +13 -0
- package/components/identity/Avatar.d.ts +26 -0
- package/components/identity/Avatar.jsx +31 -0
- package/components/identity/Avatar.prompt.md +11 -0
- package/components/identity/AvatarButton.d.ts +19 -0
- package/components/identity/AvatarButton.jsx +13 -0
- package/components/identity/AvatarButton.prompt.md +8 -0
- package/components/identity/Gemstone.d.ts +28 -0
- package/components/identity/Gemstone.jsx +46 -0
- package/components/identity/Gemstone.prompt.md +15 -0
- package/components/identity/PersonTile.d.ts +22 -0
- package/components/identity/PersonTile.jsx +27 -0
- package/components/identity/PersonTile.prompt.md +10 -0
- package/components/levels/ChoiceButton.d.ts +20 -0
- package/components/levels/ChoiceButton.jsx +31 -0
- package/components/levels/ChoiceButton.prompt.md +10 -0
- package/components/map/DiscoveryMarker.d.ts +19 -0
- package/components/map/DiscoveryMarker.jsx +31 -0
- package/components/map/DiscoveryMarker.prompt.md +11 -0
- package/components/map/LevelNode.d.ts +19 -0
- package/components/map/LevelNode.jsx +37 -0
- package/components/map/LevelNode.prompt.md +11 -0
- package/components/map/PortraitMedallion.d.ts +27 -0
- package/components/map/PortraitMedallion.jsx +29 -0
- package/components/map/PortraitMedallion.prompt.md +14 -0
- package/components/map/StarRating.d.ts +22 -0
- package/components/map/StarRating.jsx +21 -0
- package/components/map/StarRating.prompt.md +6 -0
- package/components/map/Tile.d.ts +22 -0
- package/components/map/Tile.jsx +32 -0
- package/components/map/Tile.prompt.md +8 -0
- package/components/map/WorldGate.d.ts +21 -0
- package/components/map/WorldGate.jsx +27 -0
- package/components/map/WorldGate.prompt.md +13 -0
- package/components/map/worlds.d.ts +18 -0
- package/components/map/worlds.js +9 -0
- package/components/media/Album.d.ts +33 -0
- package/components/media/Album.jsx +157 -0
- package/components/media/Album.prompt.md +16 -0
- package/components/media/Cassette.d.ts +23 -0
- package/components/media/Cassette.jsx +64 -0
- package/components/media/Cassette.prompt.md +13 -0
- package/components/media/Film.d.ts +20 -0
- package/components/media/Film.jsx +39 -0
- package/components/media/Film.prompt.md +14 -0
- package/components/media/MemoryText.d.ts +20 -0
- package/components/media/MemoryText.jsx +14 -0
- package/components/media/MemoryText.prompt.md +9 -0
- package/components/media/Unavailable.d.ts +13 -0
- package/components/media/Unavailable.jsx +13 -0
- package/components/media/Unavailable.prompt.md +10 -0
- package/components/media/Words.d.ts +21 -0
- package/components/media/Words.jsx +57 -0
- package/components/media/Words.prompt.md +12 -0
- package/components/media/shared.js +19 -0
- package/components/panels/Modal.d.ts +25 -0
- package/components/panels/Modal.jsx +68 -0
- package/components/panels/Modal.prompt.md +20 -0
- package/components/panels/Panel.d.ts +29 -0
- package/components/panels/Panel.jsx +109 -0
- package/components/panels/Panel.prompt.md +13 -0
- package/components/quiz/LeaderboardRow.d.ts +26 -0
- package/components/quiz/LeaderboardRow.jsx +24 -0
- package/components/quiz/LeaderboardRow.prompt.md +10 -0
- package/components/rewards/ItemCell.d.ts +24 -0
- package/components/rewards/ItemCell.jsx +24 -0
- package/components/rewards/ItemCell.prompt.md +9 -0
- package/components/rewards/RewardCard.d.ts +24 -0
- package/components/rewards/RewardCard.jsx +34 -0
- package/components/rewards/RewardCard.prompt.md +8 -0
- package/components/rewards/ShopRow.d.ts +23 -0
- package/components/rewards/ShopRow.jsx +26 -0
- package/components/rewards/ShopRow.prompt.md +7 -0
- package/components/text/Heading.d.ts +19 -0
- package/components/text/Heading.jsx +15 -0
- package/components/text/Heading.prompt.md +10 -0
- package/components/text/OutlineText.d.ts +24 -0
- package/components/text/OutlineText.jsx +20 -0
- package/components/text/OutlineText.prompt.md +11 -0
- package/components/toolbar/FastTravelDock.d.ts +50 -0
- package/components/toolbar/FastTravelDock.jsx +36 -0
- package/components/toolbar/FastTravelDock.prompt.md +12 -0
- package/components/toolbar/Toolbar.d.ts +16 -0
- package/components/toolbar/Toolbar.jsx +8 -0
- package/components/toolbar/Toolbar.prompt.md +14 -0
- package/components/toolbar/ToolbarNext.d.ts +21 -0
- package/components/toolbar/ToolbarNext.jsx +21 -0
- package/components/toolbar/ToolbarNext.prompt.md +13 -0
- package/components/toolbar/ToolbarTab.d.ts +15 -0
- package/components/toolbar/ToolbarTab.jsx +12 -0
- package/components/toolbar/ToolbarTab.prompt.md +9 -0
- package/components/viewer/ClueCard.d.ts +18 -0
- package/components/viewer/ClueCard.jsx +14 -0
- package/components/viewer/ClueCard.prompt.md +9 -0
- package/components/viewer/ClueTab.d.ts +16 -0
- package/components/viewer/ClueTab.jsx +12 -0
- package/components/viewer/ClueTab.prompt.md +8 -0
- package/components/viewer/RevealTray.d.ts +54 -0
- package/components/viewer/RevealTray.jsx +58 -0
- package/components/viewer/RevealTray.prompt.md +21 -0
- package/components/viewer/RoadsideViewer.d.ts +60 -0
- package/components/viewer/RoadsideViewer.jsx +96 -0
- package/components/viewer/RoadsideViewer.prompt.md +20 -0
- package/components/viewer/ViewerKeys.d.ts +26 -0
- package/components/viewer/ViewerKeys.jsx +22 -0
- package/components/viewer/ViewerKeys.prompt.md +14 -0
- package/components/viewer/ViewerKnob.d.ts +20 -0
- package/components/viewer/ViewerKnob.jsx +12 -0
- package/components/viewer/ViewerKnob.prompt.md +11 -0
- package/components/viewer/WhoButton.d.ts +21 -0
- package/components/viewer/WhoButton.jsx +15 -0
- package/components/viewer/WhoButton.prompt.md +10 -0
- package/package.json +62 -4
- package/readme.md +145 -0
- package/styles.css +15 -0
- package/tokens/colors.css +140 -0
- package/tokens/effects.css +40 -0
- package/tokens/fonts.css +3 -0
- package/tokens/fx.css +186 -0
- package/tokens/gems.css +8 -0
- package/tokens/hud.css +510 -0
- package/tokens/icons.css +7 -0
- package/tokens/media.css +774 -0
- package/tokens/signs.css +459 -0
- package/tokens/spacing.css +38 -0
- package/tokens/typography.css +34 -0
- package/tokens/ui.css +432 -0
- package/tokens/utilities.css +34 -0
- package/tokens/viewer.css +1176 -0
- package/tokens/worlds.css +73 -0
- package/tools/ds-loader.js +60 -0
- package/README.md +0 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export function StarRating({ value = 0, max = 3, size = 28, arc = false, animate = false, gap, style, ...rest }) {
|
|
5
|
+
const stars = [];
|
|
6
|
+
for (let i = 0; i < max; i++) {
|
|
7
|
+
const on = i < value;
|
|
8
|
+
const mid = arc && max % 2 === 1 && i === Math.floor(max / 2);
|
|
9
|
+
const side = arc && !mid ? (i < max / 2 ? -1 : 1) : 0;
|
|
10
|
+
const s = mid ? size * 1.35 : size;
|
|
11
|
+
stars.push(
|
|
12
|
+
<span key={i} aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: s, height: s, lineHeight: 1,
|
|
13
|
+
transform: arc ? 'translateY(' + (mid ? -size * 0.35 : 0) + 'px) rotate(' + (side * 14) + 'deg)' : 'none',
|
|
14
|
+
filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.22))', animation: animate && on ? 'mp-pop var(--dur-slow) var(--ease-bounce) both ' + (i * 140) + 'ms' : 'none' }}>
|
|
15
|
+
<i className="ph-fill ph-star" style={{ fontSize: s, lineHeight: 1, color: on ? 'var(--gold-500)' : 'var(--stone-300)',
|
|
16
|
+
WebkitTextStroke: (s * 0.1) + 'px ' + (on ? 'var(--gold-700)' : 'var(--stone-700)'), paintOrder: 'stroke fill' }} />
|
|
17
|
+
</span>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return <span role="img" aria-label={value + ' of ' + max + ' stars'} style={{ display: 'inline-flex', alignItems: 'flex-end', gap: gap == null ? Math.round(size * 0.05) : gap, ...style }} {...rest}>{stars}</span>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 3D board tile (rounded square with a solid side) for grid/path boards; variants tint the tile by terrain or purpose.
|
|
5
|
+
* @startingPoint section="Map" subtitle="Terrain tiles: grass, sand, stone, water, reward, hazard, start" viewport="700x180"
|
|
6
|
+
*/
|
|
7
|
+
export interface TileProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style' | 'content'> {
|
|
8
|
+
variant?: 'grass' | 'sand' | 'stone' | 'water' | 'reward' | 'hazard' | 'start';
|
|
9
|
+
/** Tile width in px. Default 64. */
|
|
10
|
+
size?: number;
|
|
11
|
+
/** Centered content: a GameIcon, a number, a piece. Text renders outlined in display type. */
|
|
12
|
+
content?: React.ReactNode;
|
|
13
|
+
/** Gold glow ring: reachable / selected tile. */
|
|
14
|
+
active?: boolean;
|
|
15
|
+
/** Makes the tile a button. */
|
|
16
|
+
onClick?: () => void;
|
|
17
|
+
/** Accessible name when clickable. */
|
|
18
|
+
label?: string;
|
|
19
|
+
style?: React.CSSProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Tile(props: TileProps): React.JSX.Element;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const VARIANTS = {
|
|
5
|
+
// [top, face, side, text stroke]
|
|
6
|
+
grass: ['var(--green-300)', 'var(--green-500)', 'var(--green-700)', 'var(--green-900)'],
|
|
7
|
+
sand: ['var(--cream-50)', 'var(--cream-200)', 'var(--wood-300)', 'var(--wood-700)'],
|
|
8
|
+
stone: ['var(--stone-100)', 'var(--stone-300)', 'var(--stone-500)', 'var(--stone-700)'],
|
|
9
|
+
water: ['var(--blue-100)', 'var(--blue-300)', 'var(--blue-500)', 'var(--blue-700)'],
|
|
10
|
+
reward: ['var(--gold-300)', 'var(--gold-500)', 'var(--gold-700)', 'var(--gold-900)'],
|
|
11
|
+
hazard: ['var(--berry-300)', 'var(--berry-500)', 'var(--berry-700)', 'var(--berry-900)'],
|
|
12
|
+
start: ['var(--teal-300)', 'var(--teal-500)', 'var(--teal-700)', 'var(--teal-900)'],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function Tile({ variant = 'grass', size = 64, content, active = false, onClick, label, style, ...rest }) {
|
|
16
|
+
const [down, setDown] = useState(false);
|
|
17
|
+
const [top, mid, side, stroke] = VARIANTS[variant] || VARIANTS.grass;
|
|
18
|
+
const depth = Math.round(size * 0.16);
|
|
19
|
+
const lift = down && onClick ? Math.round(depth * 0.4) : depth;
|
|
20
|
+
const Tag = onClick ? 'button' : 'div';
|
|
21
|
+
return (
|
|
22
|
+
<Tag type={onClick ? 'button' : undefined} onClick={onClick} aria-label={label} onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
|
|
23
|
+
style={{ appearance: 'none', border: 0, margin: 0, padding: 0, background: 'none', cursor: onClick ? 'pointer' : 'default', position: 'relative', display: 'inline-block', width: size, height: Math.round(size * 0.82) + depth, flex: 'none', WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
|
|
24
|
+
<span aria-hidden="true" style={{ position: 'absolute', left: 0, right: 0, top: depth - lift, height: Math.round(size * 0.82), borderRadius: Math.round(size * 0.28), boxSizing: 'border-box',
|
|
25
|
+
background: 'linear-gradient(180deg, ' + top + ', ' + mid + ' 70%)', border: '3px solid ' + top,
|
|
26
|
+
boxShadow: '0 ' + lift + 'px 0 ' + side + ', 0 ' + (lift + 4) + 'px 10px oklch(0.2 0.05 60 / 0.3)' + (active ? ', 0 0 0 4px oklch(0.92 0.14 90 / 0.9), 0 0 24px oklch(0.86 0.17 85 / 0.8)' : ''),
|
|
27
|
+
transition: 'top var(--dur-fast), box-shadow var(--dur-fast)', display: 'grid', placeItems: 'center' }}>
|
|
28
|
+
<span style={{ display: 'grid', placeItems: 'center', fontFamily: 'var(--font-display)', color: '#fff', fontSize: Math.round(size * 0.36), lineHeight: 1, WebkitTextStroke: Math.round(size * 0.05) + 'px ' + stroke, paintOrder: 'stroke fill' }}>{content}</span>
|
|
29
|
+
</span>
|
|
30
|
+
</Tag>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Board tile: for dice/board expeditions and grid puzzles. Lay tiles in a CSS grid; leave 6–8px gaps so the sides read.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<Tile variant="sand" content="?" />
|
|
5
|
+
<Tile variant="reward" content={<GameIcon kind="coin" size={30} />} active onClick={pick} label="Coin tile" />
|
|
6
|
+
<Tile variant="hazard" content={<GameIcon name="skull" tone="white" size={28} />} />
|
|
7
|
+
<Tile variant="water" size={56} />
|
|
8
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The sign that marks where a world starts on the map: a plank on two posts, tinted with the world's accent, with its glyph, its name and a count. An optional button underneath opens the author's note for that world.
|
|
5
|
+
* @startingPoint section="Map" subtitle="The sign at the start of a world" viewport="700x380"
|
|
6
|
+
*/
|
|
7
|
+
export interface WorldGateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
world?: 'mango_grove' | 'small_chops' | 'good_energy' | 'japan' | 'summit';
|
|
9
|
+
/** Overrides the world's display name. */
|
|
10
|
+
name?: string;
|
|
11
|
+
/** Overrides the world's glyph, a Phosphor icon name. */
|
|
12
|
+
glyph?: string;
|
|
13
|
+
/** The line under the name, such as "4 memories" or "Surprises only". Leave it out for none. */
|
|
14
|
+
detail?: string;
|
|
15
|
+
/** Shows a "note" button under the sign. Called when it's pressed. */
|
|
16
|
+
onNote?: () => void;
|
|
17
|
+
/** Label on the note button. Default "A note from the author". */
|
|
18
|
+
noteLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function WorldGate(props: WorldGateProps): React.JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { WORLDS } from './worlds.js';
|
|
4
|
+
|
|
5
|
+
/* The sign that marks where a world starts on the map: a plank on two posts, the world's name and a count. Place it
|
|
6
|
+
absolutely on the map (it centres itself on `left` and stands on `top`), and set its `width` from the caller. */
|
|
7
|
+
export function WorldGate({ world = 'mango_grove', name, glyph, detail, onNote, noteLabel = 'A note from the author', ...rest }) {
|
|
8
|
+
const W = WORLDS[world] || WORLDS.mango_grove;
|
|
9
|
+
return (
|
|
10
|
+
<div className="mp-gate" {...rest}>
|
|
11
|
+
<div className="mp-gate-sign" data-world={world}>
|
|
12
|
+
<span className="mp-gate-post" aria-hidden="true" />
|
|
13
|
+
<span className="mp-gate-post" aria-hidden="true" />
|
|
14
|
+
<h2 className="mp-gate-title">
|
|
15
|
+
<i className={'ph-fill ph-' + (glyph || W.glyph)} aria-hidden="true" />
|
|
16
|
+
{name || W.name}
|
|
17
|
+
</h2>
|
|
18
|
+
{detail && <span className="mp-gate-count">{detail}</span>}
|
|
19
|
+
</div>
|
|
20
|
+
{onNote && (
|
|
21
|
+
<button type="button" className="mp-gate-note" onClick={onNote}>
|
|
22
|
+
<i className="ph-fill ph-envelope-simple" aria-hidden="true" /> {noteLabel}
|
|
23
|
+
</button>
|
|
24
|
+
)}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
World gate: the sign where the road enters a world. Place one at each world's boundary on the map.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<div style={{ position: 'relative', height: 360 }}>
|
|
5
|
+
<WorldGate world="small_chops" detail="4 memories" onNote={() => openNote('small_chops')} style={{ left: 190, top: 220, width: 300 }} />
|
|
6
|
+
</div>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- Position it from the caller. It sits at `left` and `top` of its box, centres itself on `left`, and stands on `top`, so the posts reach the ground. Set `width` from the caller as well. Around 230 to 340 works.
|
|
10
|
+
- The sign sets `data-world`, so the plank takes that world's accent, and its own outline colour.
|
|
11
|
+
- The count under the name is your call. A world with nothing in it can say "Surprises only".
|
|
12
|
+
- Pass `onNote` only when the author has written a note for that world. If there's no note, there's no button.
|
|
13
|
+
- The container ignores pointer events so it never blocks the map. Only the note button takes taps.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type WorldId = 'mango_grove' | 'small_chops' | 'good_energy' | 'japan' | 'summit';
|
|
2
|
+
|
|
3
|
+
export interface WorldInfo {
|
|
4
|
+
id: WorldId;
|
|
5
|
+
/** The display name. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** A Phosphor icon name without the prefix. */
|
|
8
|
+
glyph: string;
|
|
9
|
+
/** One line on what belongs in this world. */
|
|
10
|
+
blurb: string;
|
|
11
|
+
/** A short note on the scenery, for people drawing it. */
|
|
12
|
+
scenery: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every world by id. The ids match the data contract. */
|
|
16
|
+
export const WORLDS: Record<WorldId, WorldInfo>;
|
|
17
|
+
/** The order the worlds appear along the road. */
|
|
18
|
+
export const WORLD_ORDER: WorldId[];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared world metadata for the Birthday Adventure (ids match the data contract). Glyphs are Phosphor names.
|
|
2
|
+
export const WORLDS = {
|
|
3
|
+
mango_grove: { id: 'mango_grove', name: 'Mango Grove', glyph: 'orange', blurb: 'First impressions, meeting, early memories', scenery: 'Sunny mango orchard' },
|
|
4
|
+
small_chops: { id: 'small_chops', name: 'Small Chops, Big Gist', glyph: 'bowl-food', blurb: 'Funny stories, inside jokes, outings', scenery: 'Restaurants, rice, lively stalls' },
|
|
5
|
+
good_energy: { id: 'good_energy', name: 'The Good Energy Hills', glyph: 'barbell', blurb: 'Appreciation, support, showing up', scenery: 'Green hills with gentle workout details' },
|
|
6
|
+
japan: { id: 'japan', name: 'Someday in Japan', glyph: 'lamp-pendant', blurb: 'Wishes, future adventures, hopes', scenery: 'Lanterns, gardens, distant mountain' },
|
|
7
|
+
summit: { id: 'summit', name: 'The Summit', glyph: 'envelope', blurb: 'The author’s letter', scenery: 'Golden hour above the clouds' },
|
|
8
|
+
};
|
|
9
|
+
export const WORLD_ORDER = ['mango_grove', 'small_chops', 'good_energy', 'japan', 'summit'];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface AlbumPhoto {
|
|
4
|
+
src?: string;
|
|
5
|
+
/** Alt text. Falls back to "Photo n". */
|
|
6
|
+
alt?: string;
|
|
7
|
+
caption?: string;
|
|
8
|
+
/** The photo's own size in px. Used for its shape until it loads, then the file's own size wins. */
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
/** Stable key. Defaults to `src`. */
|
|
12
|
+
id?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Photos in a box, one at a time on a tilted polaroid, at their own shape. A strip of thumbnails jumps around, arrows and swipes page through, and tapping a photo opens it full screen in a lightbox.
|
|
17
|
+
* @startingPoint section="Media" subtitle="Photos, a strip and a lightbox" viewport="700x520"
|
|
18
|
+
*/
|
|
19
|
+
export interface AlbumProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
20
|
+
/** Photos without a `src` are skipped. If none have one, the album shows an Unavailable card. */
|
|
21
|
+
photos: AlbumPhoto[];
|
|
22
|
+
/** The photo on show. Leave it out and the album keeps its own place. */
|
|
23
|
+
index?: number;
|
|
24
|
+
onIndex?: (index: number) => void;
|
|
25
|
+
/** A photo failed to load, or Try again was pressed on the Unavailable card. */
|
|
26
|
+
onError?: () => void;
|
|
27
|
+
/** Called with "tick" when paging and "tap" when opening a photo, so you can play a sound. */
|
|
28
|
+
onSound?: (name: 'tick' | 'tap') => void;
|
|
29
|
+
/** Where the lightbox is drawn. Default `document.body`. Pass an element inside a themed wrapper if your tokens are scoped. */
|
|
30
|
+
lightboxRoot?: HTMLElement | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Album(props: AlbumProps): React.JSX.Element;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
import { reduceMotion, useLoaded } from './shared.js';
|
|
5
|
+
import { Unavailable } from './Unavailable';
|
|
6
|
+
|
|
7
|
+
const shapeOf = p => (p.width && p.height ? { w: p.width, h: p.height } : null);
|
|
8
|
+
const shapeVars = (s, caption) => ({ '--w': s ? s.w : 4, '--h': s ? s.h : 3, '--cap': caption ? '34px' : '0px' });
|
|
9
|
+
const keyOf = p => p.id ?? p.src;
|
|
10
|
+
|
|
11
|
+
function Lightbox({ photos, index, onIndex, onClose, root }) {
|
|
12
|
+
const b = photos[index];
|
|
13
|
+
const close = useRef(null);
|
|
14
|
+
const [leaving, setLeaving] = useState(false);
|
|
15
|
+
const step = useCallback(d => onIndex(Math.max(0, Math.min(photos.length - 1, index + d))), [index, onIndex, photos.length]);
|
|
16
|
+
// Fade out before going, like every other sheet.
|
|
17
|
+
const leave = useCallback(() => {
|
|
18
|
+
if (reduceMotion()) return onClose();
|
|
19
|
+
setLeaving(true);
|
|
20
|
+
window.setTimeout(onClose, 180);
|
|
21
|
+
}, [onClose]);
|
|
22
|
+
useEffect(() => { if (close.current) close.current.focus(); }, []);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
// Capture phase, so Escape closes the photo and not whatever sheet is behind it. Every key it handles stops here:
|
|
25
|
+
// the lightbox sits inside the album in the React tree, so the album's own arrow keys would step a second time.
|
|
26
|
+
const onKey = e => {
|
|
27
|
+
if (e.key === 'Escape') { e.stopImmediatePropagation(); leave(); }
|
|
28
|
+
else if (e.key === 'ArrowLeft') { e.stopImmediatePropagation(); step(-1); }
|
|
29
|
+
else if (e.key === 'ArrowRight') { e.stopImmediatePropagation(); step(1); }
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener('keydown', onKey, true);
|
|
32
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
33
|
+
}, [leave, step]);
|
|
34
|
+
return createPortal(
|
|
35
|
+
<div className={'mp-lightbox' + (leaving ? ' mp-lightbox-leaving' : '')} role="dialog" aria-modal="true" aria-label={'Photo ' + (index + 1) + ' of ' + photos.length} onClick={leave}>
|
|
36
|
+
<img key={keyOf(b)} src={b.src} alt={b.alt || 'Photo ' + (index + 1)} />
|
|
37
|
+
{b.caption && <p className="mp-light-caption">{b.caption}</p>}
|
|
38
|
+
<button ref={close} type="button" className="mp-light-close" onClick={e => { e.stopPropagation(); leave(); }} aria-label="Close the photo">
|
|
39
|
+
<i className="ph-bold ph-x" aria-hidden="true" />
|
|
40
|
+
</button>
|
|
41
|
+
{photos.length > 1 && (
|
|
42
|
+
<>
|
|
43
|
+
<button type="button" className="mp-album-arrow mp-album-arrow-prev" disabled={index === 0} onClick={e => { e.stopPropagation(); step(-1); }} aria-label="Previous photo">
|
|
44
|
+
<i className="ph-bold ph-caret-left" aria-hidden="true" />
|
|
45
|
+
</button>
|
|
46
|
+
<button type="button" className="mp-album-arrow mp-album-arrow-next" disabled={index === photos.length - 1} onClick={e => { e.stopPropagation(); step(1); }} aria-label="Next photo">
|
|
47
|
+
<i className="ph-bold ph-caret-right" aria-hidden="true" />
|
|
48
|
+
</button>
|
|
49
|
+
</>
|
|
50
|
+
)}
|
|
51
|
+
</div>,
|
|
52
|
+
root || document.body,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* One photo at a time, at its own shape, with a strip to jump around. Tap the photo to see it full screen.
|
|
57
|
+
Fills the box it sits in (position: absolute; inset: 0), so put it in a positioned box with a size. */
|
|
58
|
+
export function Album({ photos: all = [], index: controlled, onIndex, onError, onSound, lightboxRoot, ...rest }) {
|
|
59
|
+
const photos = useMemo(() => all.filter(p => p.src), [all]);
|
|
60
|
+
const [own, setOwn] = useState(0);
|
|
61
|
+
const index = controlled === undefined ? own : controlled;
|
|
62
|
+
const at = Math.min(index, Math.max(0, photos.length - 1));
|
|
63
|
+
const [natural, setNatural] = useState({});
|
|
64
|
+
const [zoomed, setZoomed] = useState(false);
|
|
65
|
+
const strip = useRef(null);
|
|
66
|
+
const touch = useRef(null);
|
|
67
|
+
const many = photos.length > 1;
|
|
68
|
+
const { loaded: photoLoaded, done: photoDone, checkImage } = useLoaded(photos[at] && photos[at].src);
|
|
69
|
+
// A server-rendered photo can arrive before React is listening for its load, so read its size from the element too.
|
|
70
|
+
const photoRef = useCallback(el => {
|
|
71
|
+
checkImage(el);
|
|
72
|
+
if (el && el.complete && el.naturalWidth > 0) {
|
|
73
|
+
const w = el.naturalWidth, h = el.naturalHeight, k = keyOf(photos[at]);
|
|
74
|
+
setNatural(n => (n[k] && n[k].w === w && n[k].h === h ? n : { ...n, [k]: { w, h } }));
|
|
75
|
+
}
|
|
76
|
+
}, [checkImage, photos, at]);
|
|
77
|
+
|
|
78
|
+
const go = useCallback(i => {
|
|
79
|
+
const to = Math.max(0, Math.min(photos.length - 1, i));
|
|
80
|
+
if (to === Math.min(index, photos.length - 1)) return;
|
|
81
|
+
if (onSound) onSound('tick');
|
|
82
|
+
setOwn(to);
|
|
83
|
+
if (onIndex) onIndex(to);
|
|
84
|
+
}, [index, onIndex, onSound, photos.length]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const el = strip.current;
|
|
88
|
+
const thumb = el && el.children[at];
|
|
89
|
+
if (!el || !thumb) return;
|
|
90
|
+
el.scrollTo({ left: thumb.offsetLeft - el.clientWidth / 2 + thumb.clientWidth / 2, behavior: reduceMotion() ? 'auto' : 'smooth' });
|
|
91
|
+
}, [at]);
|
|
92
|
+
|
|
93
|
+
// Fetch the neighbours ahead so the next photo is there when it's tapped.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
for (const n of [photos[at + 1], photos[at - 1]]) { if (n && n.src) new window.Image().src = n.src; }
|
|
96
|
+
}, [at, photos]);
|
|
97
|
+
|
|
98
|
+
if (!photos.length) return <Unavailable what={all.length > 1 ? 'set of photos' : 'photo'} onRetry={onError} />;
|
|
99
|
+
const b = photos[at];
|
|
100
|
+
// The file's own size wins once it has loaded: it knows about phone photos saved on their side.
|
|
101
|
+
const shape = natural[keyOf(b)] || shapeOf(b);
|
|
102
|
+
|
|
103
|
+
const arrows = [
|
|
104
|
+
<button key="prev" type="button" className="mp-album-arrow mp-album-arrow-prev" disabled={at === 0} onClick={() => go(at - 1)} aria-label="Previous photo">
|
|
105
|
+
<i className="ph-bold ph-caret-left" aria-hidden="true" />
|
|
106
|
+
</button>,
|
|
107
|
+
<button key="next" type="button" className="mp-album-arrow mp-album-arrow-next" disabled={at === photos.length - 1} onClick={() => go(at + 1)} aria-label="Next photo">
|
|
108
|
+
<i className="ph-bold ph-caret-right" aria-hidden="true" />
|
|
109
|
+
</button>,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="mp-album" onKeyDown={e => { if (e.key === 'ArrowLeft') go(at - 1); else if (e.key === 'ArrowRight') go(at + 1); }} {...rest}>
|
|
114
|
+
<div className="mp-album-frame"
|
|
115
|
+
onTouchStart={e => { touch.current = e.touches[0].clientX; }}
|
|
116
|
+
onTouchEnd={e => {
|
|
117
|
+
const from = touch.current;
|
|
118
|
+
touch.current = null;
|
|
119
|
+
if (from === null || !many) return;
|
|
120
|
+
const dx = e.changedTouches[0].clientX - from;
|
|
121
|
+
if (Math.abs(dx) > 40) go(at + (dx < 0 ? 1 : -1));
|
|
122
|
+
}}>
|
|
123
|
+
<figure key={keyOf(b)} className="mp-polaroid" style={{ ...shapeVars(shape, Boolean(b.caption)), '--tilt': (at % 2 ? 1.5 : -1.5) + 'deg' }}>
|
|
124
|
+
<button type="button" className="mp-photo-button mp-media" data-loaded={photoLoaded} onClick={() => { if (onSound) onSound('tap'); setZoomed(true); }} aria-label="See this photo full screen">
|
|
125
|
+
<span className="mp-shimmer mp-on-cream" aria-hidden="true" />
|
|
126
|
+
<img ref={photoRef} src={b.src} alt={b.alt || (many ? 'Photo ' + (at + 1) : 'A photo from this memory')}
|
|
127
|
+
onLoad={e => {
|
|
128
|
+
photoDone();
|
|
129
|
+
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
|
|
130
|
+
const k = keyOf(b);
|
|
131
|
+
if (w && h) setNatural(n => (n[k] && n[k].w === w && n[k].h === h ? n : { ...n, [k]: { w, h } }));
|
|
132
|
+
}}
|
|
133
|
+
onError={onError} />
|
|
134
|
+
<span className="mp-photo-zoom" aria-hidden="true"><i className="ph-bold ph-arrows-out-simple" /></span>
|
|
135
|
+
</button>
|
|
136
|
+
{b.caption && <figcaption>{b.caption}</figcaption>}
|
|
137
|
+
</figure>
|
|
138
|
+
{many && <>{arrows}</>}
|
|
139
|
+
</div>
|
|
140
|
+
{many && (
|
|
141
|
+
<div className="mp-strip-row">
|
|
142
|
+
{/* On a phone the arrows live down here, clear of the photo. */}
|
|
143
|
+
{arrows}
|
|
144
|
+
<div ref={strip} className="mp-album-strip">
|
|
145
|
+
{photos.map((p, i) => (
|
|
146
|
+
<button key={keyOf(p)} type="button" className={'mp-album-thumb' + (i === at ? ' mp-album-thumb-on' : '')} onClick={() => go(i)} aria-label={'Photo ' + (i + 1)} aria-current={i === at ? 'true' : undefined}>
|
|
147
|
+
<img src={p.src} alt="" loading="lazy" />
|
|
148
|
+
</button>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
<span className="mp-album-count">{at + 1} / {photos.length}</span>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{zoomed && <Lightbox photos={photos} index={at} onIndex={go} onClose={() => setZoomed(false)} root={lightboxRoot} />}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Album: photos in a box, one at a time. Give it a positioned box with a size. It fills that box.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<div style={{ position: 'relative', height: 520, containerType: 'size' }}>
|
|
5
|
+
<Album photos={photos} index={at} onIndex={setAt} onError={refresh} onSound={play} />
|
|
6
|
+
</div>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- Each photo sits on a slightly tilted polaroid at its own shape, so tall screenshots and square pictures don't get cropped. Pass `width` and `height` when you know them. The file's own size wins once it loads.
|
|
10
|
+
- With more than one photo, a strip of thumbnails sits underneath. Arrows, the arrow keys and a swipe all page through. The next and previous photos load ahead of time.
|
|
11
|
+
- Tapping a photo opens it full screen in a lightbox. Escape closes the photo and not the sheet behind it.
|
|
12
|
+
- A photo shows a shimmer until it arrives, then fades in.
|
|
13
|
+
- Photos without a `src` are skipped. If none are left, an `Unavailable` card shows, and `onError` is its Try again.
|
|
14
|
+
- The lightbox is drawn on `document.body`. If your tokens are scoped to a wrapper, pass `lightboxRoot` so it gets them.
|
|
15
|
+
- Never auto-play or auto-advance. Paging is always the viewer's call.
|
|
16
|
+
- Use neutral captions, never the sender.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A voice note as a cassette: two reels that spin while it plays, a wave, a play button, a seek bar and the time. Never plays by itself, and it stays put when it ends.
|
|
5
|
+
* @startingPoint section="Media" subtitle="Voice note on a cassette" viewport="700x340"
|
|
6
|
+
*/
|
|
7
|
+
export interface CassetteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
|
8
|
+
/** Audio URL. Without one, an Unavailable card shows. */
|
|
9
|
+
src?: string;
|
|
10
|
+
/** Label on the tape. Default "Voice note". Keep it neutral during a mystery, never the sender's name. */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Length in seconds, shown until the file reports its own. Recordings often report nothing until played through. */
|
|
13
|
+
duration?: number;
|
|
14
|
+
/** A line under the cassette. */
|
|
15
|
+
heading?: string;
|
|
16
|
+
/** A smaller line under the heading. */
|
|
17
|
+
hint?: string;
|
|
18
|
+
onError?: () => void;
|
|
19
|
+
/** Called with "tap" when the play button is pressed, so you can play a sound. */
|
|
20
|
+
onSound?: (name: 'tap') => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Cassette(props: CassetteProps): React.JSX.Element;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { clock, finite } from './shared.js';
|
|
4
|
+
import { Unavailable } from './Unavailable';
|
|
5
|
+
|
|
6
|
+
/* A voice note as a cassette: two reels that spin while it plays, a wave, a play button and a seek bar. */
|
|
7
|
+
export function Cassette({ src, title = 'Voice note', duration = 0, heading, hint, onError, onSound, ...rest }) {
|
|
8
|
+
const audio = useRef(null);
|
|
9
|
+
const [playing, setPlaying] = useState(false);
|
|
10
|
+
const [t, setT] = useState(0);
|
|
11
|
+
const [dur, setDur] = useState(duration);
|
|
12
|
+
// A server-rendered audio element can know its length before React is listening.
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const a = audio.current;
|
|
15
|
+
if (a && a.readyState >= 1) setDur(finite(a.duration) || duration);
|
|
16
|
+
}, [src, duration]);
|
|
17
|
+
if (!src) return <Unavailable what="voice note" onRetry={onError} />;
|
|
18
|
+
|
|
19
|
+
const known = e => setDur(finite(e.currentTarget.duration) || duration);
|
|
20
|
+
const toggle = () => {
|
|
21
|
+
const a = audio.current;
|
|
22
|
+
if (!a) return;
|
|
23
|
+
if (onSound) onSound('tap');
|
|
24
|
+
if (a.paused) a.play().catch(() => setPlaying(false));
|
|
25
|
+
else a.pause();
|
|
26
|
+
};
|
|
27
|
+
const pct = dur ? Math.min(100, (t / dur) * 100) : 0;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={'mp-tape-deck' + (playing ? ' mp-playing' : '')} {...rest}>
|
|
31
|
+
<audio ref={audio} src={src} preload="metadata" onLoadedMetadata={known} onDurationChange={known}
|
|
32
|
+
onTimeUpdate={e => setT(e.currentTarget.currentTime)} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} onError={onError} />
|
|
33
|
+
<div className="mp-cassette">
|
|
34
|
+
<div className="mp-cassette-label">
|
|
35
|
+
<span>{title}</span>
|
|
36
|
+
<span>{dur ? clock(dur) : ''}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="mp-reels" aria-hidden="true">
|
|
39
|
+
<span className="mp-reel" />
|
|
40
|
+
<span className="mp-wave">
|
|
41
|
+
{Array.from({ length: 22 }, (_, i) => (
|
|
42
|
+
<i key={i} style={{ '--bar': Math.round(10 + Math.abs(Math.sin(i * 1.7)) * 30) + 'px', '--delay': (i % 7) * -120 + 'ms' }} />
|
|
43
|
+
))}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="mp-reel" />
|
|
46
|
+
</div>
|
|
47
|
+
<div className="mp-transport">
|
|
48
|
+
<button type="button" className="mp-cassette-play" onClick={toggle} aria-label={playing ? 'Pause' : 'Play'}>
|
|
49
|
+
<i className={'ph-fill ph-' + (playing ? 'pause' : 'play')} aria-hidden="true" />
|
|
50
|
+
</button>
|
|
51
|
+
<span className="mp-seek">
|
|
52
|
+
<span className="mp-seek-bar"><b style={{ width: pct + '%' }} /></span>
|
|
53
|
+
<input type="range" min={0} max={dur || 1} step={0.1} value={t}
|
|
54
|
+
onChange={e => { const to = Number(e.target.value); setT(to); if (audio.current) audio.current.currentTime = to; }}
|
|
55
|
+
aria-label="Seek the voice note" />
|
|
56
|
+
</span>
|
|
57
|
+
<span className="mp-cassette-time">{clock(t)}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
{heading && <h4 className="mp-tape-heading">{heading}</h4>}
|
|
61
|
+
{hint && <p className="mp-tape-hint">{hint}</p>}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Cassette: a voice note. Give it an audio URL and it does the rest.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<Cassette src={note.url} duration={note.seconds} heading="A voice note" hint="Tap play when you're ready" onError={refresh} onSound={play} />
|
|
5
|
+
<Cassette src={song.url} title="Song for you" heading="Listen" />
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
- Nothing plays until the play button is pressed. When it ends, it stays at the end, paused. The viewer taps Continue themselves.
|
|
9
|
+
- The reels and the wave move only while it plays. With reduced motion they stay still.
|
|
10
|
+
- `duration` shows until the file reports its own. Recordings made in a browser often report no length until they've been played through, and the seek bar waits for one.
|
|
11
|
+
- The seek bar is a real slider, so it works with a keyboard and a screen reader.
|
|
12
|
+
- Keep `title` neutral during a mystery, never the sender's name. A spoken name in the recording itself is the contributor's call.
|
|
13
|
+
- With no `src`, an `Unavailable` card shows instead.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A video on a strip of film, with sprocket holes down the sides. It keeps its own shape, upright ones included, loads with a shimmer, and uses the browser's controls so full screen works on a phone.
|
|
5
|
+
* @startingPoint section="Media" subtitle="Video on a film strip" viewport="700x420"
|
|
6
|
+
*/
|
|
7
|
+
export interface FilmProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
|
|
8
|
+
/** Video URL. Without one, an Unavailable card shows. */
|
|
9
|
+
src?: string;
|
|
10
|
+
/** A neutral line under the film, never the sender. */
|
|
11
|
+
caption?: string;
|
|
12
|
+
/** The video's own size in px, used for its shape until it loads. Default 16 by 9. */
|
|
13
|
+
width?: number;
|
|
14
|
+
height?: number;
|
|
15
|
+
/** Pauses the video when it turns true, for when something else opens on top. */
|
|
16
|
+
paused?: boolean;
|
|
17
|
+
onError?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Film(props: FilmProps): React.JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useLoaded } from './shared.js';
|
|
4
|
+
import { Unavailable } from './Unavailable';
|
|
5
|
+
|
|
6
|
+
/* A video on a strip of film. It keeps its own shape, upright ones included, and uses the browser's own controls,
|
|
7
|
+
so full screen works on a phone. Fills the width of the box it sits in, which is a size container. */
|
|
8
|
+
export function Film({ src, caption, width, height, paused = false, onError, ...rest }) {
|
|
9
|
+
const video = useRef(null);
|
|
10
|
+
const [natural, setNatural] = useState(null);
|
|
11
|
+
const { loaded, done } = useLoaded(src);
|
|
12
|
+
useEffect(() => { if (paused && video.current) video.current.pause(); }, [paused]);
|
|
13
|
+
// A server-rendered video can have its metadata before React is listening, and React doesn't replay that event.
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const el = video.current;
|
|
16
|
+
if (!el || el.readyState < 1) return;
|
|
17
|
+
done();
|
|
18
|
+
if (el.videoWidth && el.videoHeight) setNatural({ w: el.videoWidth, h: el.videoHeight });
|
|
19
|
+
}, [src, done]);
|
|
20
|
+
if (!src) return <Unavailable what="video" onRetry={onError} />;
|
|
21
|
+
const shape = natural || (width && height ? { w: width, h: height } : { w: 16, h: 9 });
|
|
22
|
+
return (
|
|
23
|
+
<figure className="mp-film" style={{ '--w': shape.w, '--h': shape.h, '--cap': caption ? '34px' : '0px' }} {...rest}>
|
|
24
|
+
<div className="mp-film-strip mp-media" data-loaded={loaded}>
|
|
25
|
+
<span className="mp-shimmer mp-on-dark mp-inset" aria-hidden="true" />
|
|
26
|
+
<video ref={video}
|
|
27
|
+
// The fragment asks the browser to paint the first frame instead of a black box.
|
|
28
|
+
src={src + '#t=0.001'} controls playsInline preload="metadata"
|
|
29
|
+
onLoadedMetadata={e => {
|
|
30
|
+
done();
|
|
31
|
+
const { videoWidth: w, videoHeight: h } = e.currentTarget;
|
|
32
|
+
if (w && h) setNatural({ w, h });
|
|
33
|
+
}}
|
|
34
|
+
onError={onError} />
|
|
35
|
+
</div>
|
|
36
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
37
|
+
</figure>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Film: a video on a strip of film. It fits the width of its box, which should be a size container, and keeps the video's own shape.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<div style={{ position: 'relative', height: 480, containerType: 'size', display: 'grid', placeItems: 'center' }}>
|
|
5
|
+
<Film src={clip.url} caption="The party" width={clip.width} height={clip.height} paused={sheetOpen} onError={refresh} />
|
|
6
|
+
</div>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- It uses the browser's own controls, so full screen, captions and the volume work the way people expect on their phone.
|
|
10
|
+
- It never autoplays and never loops. Pass `paused` when something opens on top of it.
|
|
11
|
+
- Until it loads, a shimmer holds its place. The first frame is painted instead of a black box.
|
|
12
|
+
- Pass `width` and `height` when you know them, so the film has the right shape before it loads. The video's own size wins once it has.
|
|
13
|
+
- Use a neutral caption, never the sender.
|
|
14
|
+
- With no `src`, an `Unavailable` card shows instead.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Readable card for a contributor's written memory (or the author’s letter): warm parchment, 20px UI type at 1.6 leading, ≤ 640px column.
|
|
5
|
+
* @startingPoint section="Media" subtitle="Written memory card" viewport="700x260"
|
|
6
|
+
*/
|
|
7
|
+
export interface MemoryTextProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style'> {
|
|
8
|
+
/** Paragraphs (use <p> elements). */
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
/** lg 20px (iPad) · md 17px (phone) · sm 15px. */
|
|
11
|
+
size?: 'lg' | 'md' | 'sm';
|
|
12
|
+
/** Gold quote-mark ornament (default true). */
|
|
13
|
+
ornament?: boolean;
|
|
14
|
+
width?: number | string;
|
|
15
|
+
/** Default var(--layout-reading-max) = 640px. */
|
|
16
|
+
maxWidth?: number | string;
|
|
17
|
+
style?: React.CSSProperties;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function MemoryText(props: MemoryTextProps): React.JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export function MemoryText({ children, size = 'lg', ornament = true, width = '100%', maxWidth = 'var(--layout-reading-max)', style, ...rest }) {
|
|
5
|
+
const fs = size === 'lg' ? 20 : size === 'md' ? 17 : 15;
|
|
6
|
+
return (
|
|
7
|
+
<div style={{ position: 'relative', width, maxWidth, boxSizing: 'border-box', padding: ornament ? '30px 26px 24px' : '22px 24px', borderRadius: 'var(--radius-xl)',
|
|
8
|
+
background: 'linear-gradient(180deg, #fff, var(--cream-100))', border: '3px solid var(--cream-300)', boxShadow: 'inset 0 3px 0 oklch(1 0 0 / 0.7), 0 6px 0 var(--cream-300), 0 10px 20px oklch(0.22 0.05 60 / 0.18)',
|
|
9
|
+
fontFamily: 'var(--font-ui)', fontWeight: 600, fontSize: fs, lineHeight: 1.6, color: 'var(--ink-900)', textWrap: 'pretty', ...style }} {...rest}>
|
|
10
|
+
{ornament && <span aria-hidden="true" style={{ position: 'absolute', left: 18, top: -8, fontFamily: 'var(--font-display)', fontSize: 64, lineHeight: 1, color: 'var(--gold-300)', textShadow: '0 2px 0 var(--gold-700)' }}>“</span>}
|
|
11
|
+
<div style={{ display: 'grid', gap: '0.8em' }}>{children}</div>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Memory text: any written contribution, and the summit letter (size lg, ornament off, letter tokens). Text is shown exactly as written; no truncation, no "read more".
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<MemoryText size="lg"><p>{memory.text}</p></MemoryText>
|
|
5
|
+
<MemoryText ornament={false} style={{ fontSize: 'var(--text-letter)', lineHeight: 1.65 }}>{letterParagraphs}</MemoryText>
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
- Never render the author's name inside or near it while the level is a mystery.
|
|
9
|
+
- On phones use size="md". Long texts scroll inside the level; the Continue button stays reachable.
|