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,18 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flip card for the brief, skippable matching game: blue "?" back, cream face with a themed symbol; matched pairs get a green ring + tick.
|
|
5
|
+
* @startingPoint section="Games" subtitle="Matching cards: down / up / matched" viewport="700x180"
|
|
6
|
+
*/
|
|
7
|
+
export interface MatchCardProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
|
|
8
|
+
symbol?: 'mango' | 'rice' | 'flower' | 'dumbbell' | 'lantern' | 'star' | 'heart' | 'leaf';
|
|
9
|
+
face?: 'down' | 'up' | 'matched';
|
|
10
|
+
/** Card width in px (height 1.15×). Default 84. */
|
|
11
|
+
size?: number;
|
|
12
|
+
onClick?: () => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
label?: string;
|
|
15
|
+
style?: React.CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MatchCard(props: MatchCardProps): React.JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const SYMBOLS = { mango: ['orange', 'var(--gold-500)', 'var(--gold-900)'], rice: ['bowl-food', '#fff', 'var(--ink-900)'], flower: ['flower', 'var(--coral-500)', 'var(--coral-900)'], dumbbell: ['barbell', 'var(--blue-500)', 'var(--blue-900)'], lantern: ['lamp-pendant', 'var(--coral-500)', 'var(--coral-900)'], star: ['star', 'var(--gold-500)', 'var(--gold-900)'], heart: ['heart', 'var(--coral-500)', 'var(--coral-900)'], leaf: ['leaf', 'var(--green-500)', 'var(--green-900)'] };
|
|
5
|
+
const reduce = () => typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
6
|
+
|
|
7
|
+
export function MatchCard({ symbol = 'mango', face = 'down', size = 84, onClick, disabled = false, label, style, ...rest }) {
|
|
8
|
+
const [glyph, fill, stroke] = SYMBOLS[symbol] || SYMBOLS.star;
|
|
9
|
+
const up = face !== 'down', matched = face === 'matched';
|
|
10
|
+
const faceStyle = { position: 'absolute', inset: 0, borderRadius: 'var(--radius-md)', backfaceVisibility: 'hidden', WebkitBackfaceVisibility: 'hidden', display: 'grid', placeItems: 'center', boxSizing: 'border-box' };
|
|
11
|
+
const swap = reduce() ? 'none' : 'opacity 0s linear 225ms';
|
|
12
|
+
return (
|
|
13
|
+
<button type="button" onClick={onClick} disabled={disabled || up} aria-label={label || (up ? symbol + (matched ? ', matched' : '') : 'Face-down card')}
|
|
14
|
+
style={{ appearance: 'none', background: 'none', border: 0, padding: 0, margin: 0, width: size, height: Math.round(size * 1.15), perspective: 600, cursor: disabled || up ? 'default' : 'pointer', flex: 'none', WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
|
|
15
|
+
<span style={{ position: 'relative', display: 'block', width: '100%', height: '100%', transformStyle: 'preserve-3d', transform: up ? 'rotateY(180deg)' : 'none', transition: reduce() ? 'none' : 'transform 450ms var(--ease-in-out)' }}>
|
|
16
|
+
<span aria-hidden="true" style={{ ...faceStyle, opacity: up ? 0 : 1, transition: swap, background: 'radial-gradient(circle at 50% 30%, var(--blue-300), var(--blue-500) 70%)', border: '3px solid var(--blue-300)', boxShadow: '0 5px 0 var(--blue-700), 0 8px 12px oklch(0.2 0.05 60 / 0.3), inset 0 2px 0 oklch(1 0 0 / 0.5)' }}>
|
|
17
|
+
<span style={{ position: 'absolute', inset: 8, borderRadius: 10, border: '2px dashed oklch(1 0 0 / 0.5)' }} />
|
|
18
|
+
<span style={{ fontFamily: 'var(--font-display)', fontSize: size * 0.42, lineHeight: 1, color: '#fff', WebkitTextStroke: (size * 0.05) + 'px var(--blue-700)', paintOrder: 'stroke fill' }}>?</span>
|
|
19
|
+
</span>
|
|
20
|
+
<span aria-hidden="true" style={{ ...faceStyle, opacity: up ? 1 : 0, transition: swap, transform: 'rotateY(180deg)', background: 'linear-gradient(180deg, #fff, var(--cream-100))', border: '3px solid ' + (matched ? 'var(--green-500)' : 'var(--cream-300)'), boxShadow: '0 5px 0 ' + (matched ? 'var(--green-700)' : 'var(--cream-300)') + ', 0 8px 12px oklch(0.2 0.05 60 / 0.2)' }}>
|
|
21
|
+
<i className={'ph-fill ph-' + glyph} style={{ fontSize: size * 0.5, lineHeight: 1, color: fill, WebkitTextStroke: (size * 0.055) + 'px ' + stroke, paintOrder: 'stroke fill', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.2))' }} />
|
|
22
|
+
{matched && <span style={{ position: 'absolute', right: -8, top: -8, width: 24, height: 24, borderRadius: '50%', background: 'var(--green-500)', border: '2px solid #fff', color: '#fff', fontSize: 13, display: 'grid', placeItems: 'center', boxShadow: '0 2px 0 var(--green-700)' }}><i className="ph-bold ph-check" /></span>}
|
|
23
|
+
</span>
|
|
24
|
+
</span>
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Match card: for levels whose media already reveals the sender: a 6-card (3 pairs) or 8-card grid, then the direct reveal. Always paired with a Skip button.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 84px)', gap: 12 }}>
|
|
5
|
+
{cards.map(c => <MatchCard key={c.id} symbol={c.symbol} face={c.face} onClick={() => flip(c.id)} />)}
|
|
6
|
+
</div>
|
|
7
|
+
<Button variant="ghost" typeface="ui">Skip</Button>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Choose the interaction once per level and persist it; don't re-roll on each render.
|
|
11
|
+
- Symbols come from the Birthday Adventure’s world: mango, rice bowl, flower, dumbbell, lantern.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live countdown that ticks every second: a wooden board with day, hour, minute and second tiles, or a ticking TimerChip for the HUD.
|
|
5
|
+
* @startingPoint section="HUD" subtitle="Live countdown: wooden board or HUD chip" viewport="700x300"
|
|
6
|
+
*/
|
|
7
|
+
export interface CountdownProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style' | 'title'> {
|
|
8
|
+
/** When it ends: a Date, an ISO string or a timestamp in ms. */
|
|
9
|
+
to: Date | string | number;
|
|
10
|
+
/** board = the wooden board with tiles (default). chip = a ticking DD:HH:MM:SS TimerChip. */
|
|
11
|
+
variant?: 'board' | 'chip';
|
|
12
|
+
/** What it counts down to, e.g. "your birthday". Read out as "3 days, 4 hours and 12 minutes until your birthday". */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** Board only: the gold tag across the top. Defaults to "Until {label}". */
|
|
15
|
+
title?: React.ReactNode;
|
|
16
|
+
/** Board only: a line under the tiles, such as the date. */
|
|
17
|
+
caption?: React.ReactNode;
|
|
18
|
+
/** Phosphor glyph name. Defaults to gift on the board and hourglass-medium on the chip. */
|
|
19
|
+
icon?: string;
|
|
20
|
+
/** What to show once the time has passed. Default nothing. */
|
|
21
|
+
ended?: React.ReactNode;
|
|
22
|
+
/** Chip only: turns urgent (coral, floating) within this many ms of the end. Default one day. */
|
|
23
|
+
urgentWithin?: number;
|
|
24
|
+
/** Chip only: TimerChip tone. */
|
|
25
|
+
tone?: 'dark' | 'light';
|
|
26
|
+
/** Chip only: TimerChip size. */
|
|
27
|
+
size?: 'sm' | 'md';
|
|
28
|
+
/** Freeze the clock at this timestamp in ms, for fixtures and screenshots. */
|
|
29
|
+
now?: number;
|
|
30
|
+
style?: React.CSSProperties;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Countdown(props: CountdownProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useSyncExternalStore } from 'react';
|
|
3
|
+
import { TimerChip } from './TimerChip';
|
|
4
|
+
|
|
5
|
+
const SECOND = 1000;
|
|
6
|
+
// One clock for every Countdown on the page, firing just after each whole second so they all change together.
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
let timer = 0;
|
|
9
|
+
const schedule = () => { timer = setTimeout(() => { listeners.forEach(tick => tick()); schedule(); }, SECOND - (Date.now() % SECOND) + 5); };
|
|
10
|
+
const tickEverySecond = tick => { listeners.add(tick); if (listeners.size === 1) schedule(); return () => { listeners.delete(tick); if (!listeners.size) clearTimeout(timer); }; };
|
|
11
|
+
const noTicks = () => () => {};
|
|
12
|
+
const clockNow = () => Math.floor(Date.now() / SECOND) * SECOND;
|
|
13
|
+
const noClockOnServer = () => null; // server and browser clocks differ, so it renders after hydration
|
|
14
|
+
const pad = n => String(n).padStart(2, '0');
|
|
15
|
+
const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's');
|
|
16
|
+
|
|
17
|
+
export function Countdown({ to, variant = 'board', label, title, caption, icon, ended = null, urgentWithin = 86400000, tone, size, now, style, ...rest }) {
|
|
18
|
+
const frozen = now != null;
|
|
19
|
+
const ticking = useSyncExternalStore(frozen ? noTicks : tickEverySecond, clockNow, noClockOnServer);
|
|
20
|
+
const current = frozen ? now : ticking;
|
|
21
|
+
if (current == null) return null;
|
|
22
|
+
const left = new Date(to).getTime() - current;
|
|
23
|
+
if (!(left > 0)) return ended;
|
|
24
|
+
const total = Math.floor(left / SECOND);
|
|
25
|
+
const days = Math.floor(total / 86400), hours = Math.floor((total % 86400) / 3600), minutes = Math.floor((total % 3600) / 60), seconds = total % 60;
|
|
26
|
+
const said = [[days, 'day'], [hours, 'hour'], [minutes, 'minute']];
|
|
27
|
+
while (said.length && said[0][0] === 0) said.shift(); // "4 hours and 2 minutes", not "0 days, 4 hours…"
|
|
28
|
+
const words = said.map(([n, w]) => plural(n, w));
|
|
29
|
+
const spoken = (words.length > 1 ? words.slice(0, -1).join(', ') + ' and ' + words[words.length - 1] : words[0] || 'Less than a minute') + (label ? ' until ' + label : ' left');
|
|
30
|
+
|
|
31
|
+
if (variant === 'chip') {
|
|
32
|
+
return <TimerChip role="timer" aria-label={spoken} icon={icon || 'hourglass-medium'} time={pad(days) + ':' + pad(hours) + ':' + pad(minutes) + ':' + pad(seconds)}
|
|
33
|
+
urgent={left <= urgentWithin} tone={tone} size={size} style={style} {...rest} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const units = [[days, 'Days'], [hours, 'Hours'], [minutes, 'Mins'], [seconds, 'Secs']];
|
|
37
|
+
return (
|
|
38
|
+
<div role="timer" aria-label={spoken} style={{ width: '100%', containerType: 'inline-size', paddingTop: 22, boxSizing: 'border-box', ...style }} {...rest}>
|
|
39
|
+
<div style={{
|
|
40
|
+
position: 'relative', boxSizing: 'border-box', padding: 'clamp(34px, 7cqi, 44px) clamp(14px, 4cqi, 26px) clamp(16px, 3.5cqi, 22px)',
|
|
41
|
+
borderRadius: 'var(--radius-xl)', border: '5px solid var(--wood-900)',
|
|
42
|
+
background: 'repeating-linear-gradient(180deg, transparent 0 18px, oklch(0 0 0 / 0.06) 18px 21px), linear-gradient(180deg, var(--wood-300), var(--wood-500))',
|
|
43
|
+
boxShadow: '0 8px 0 var(--wood-900), 0 22px 36px oklch(0.25 0.05 60 / 0.35), inset 0 4px 0 oklch(1 0 0 / 0.3)',
|
|
44
|
+
display: 'grid', justifyItems: 'center', gap: 14,
|
|
45
|
+
}}>
|
|
46
|
+
<span aria-hidden="true" style={{
|
|
47
|
+
position: 'absolute', top: -22, left: '50%', transform: 'translateX(-50%)', height: 44, padding: '0 20px', boxSizing: 'border-box',
|
|
48
|
+
borderRadius: 'var(--radius-pill)', border: '4px solid #fff', background: 'linear-gradient(180deg, var(--gold-300), var(--gold-500) 60%)', boxShadow: '0 4px 0 var(--gold-700)',
|
|
49
|
+
display: 'inline-flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
|
|
50
|
+
fontFamily: 'var(--font-display)', fontSize: 'clamp(14px, 3cqi, 17px)', letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--gold-900)',
|
|
51
|
+
}}>
|
|
52
|
+
<i className={'ph-fill ph-' + (icon || 'gift')} style={{ fontSize: '1.2em' }} />
|
|
53
|
+
{title || (label ? 'Until ' + label : 'Time left')}
|
|
54
|
+
</span>
|
|
55
|
+
<div aria-hidden="true" style={{ width: '100%', display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 'clamp(8px, 2.4cqi, 14px)' }}>
|
|
56
|
+
{units.map(([value, unit]) => (
|
|
57
|
+
<div key={unit} style={{ display: 'grid', justifyItems: 'center', gap: 8, minWidth: 0 }}>
|
|
58
|
+
<span style={{
|
|
59
|
+
position: 'relative', width: '100%', aspectRatio: '1 / 1.04', boxSizing: 'border-box', borderRadius: 'clamp(14px, 3.5cqi, 20px)', overflow: 'hidden',
|
|
60
|
+
background: 'linear-gradient(180deg, #fff 0%, var(--cream-100) 48%, var(--cream-200) 100%)', boxShadow: '0 6px 0 var(--wood-700), inset 0 3px 0 oklch(1 0 0 / 0.8)',
|
|
61
|
+
display: 'grid', placeItems: 'center',
|
|
62
|
+
}}>
|
|
63
|
+
<span style={{ fontFamily: 'var(--font-display)', fontSize: 'clamp(28px, 10cqi, 64px)', lineHeight: 1, color: 'var(--blue-900)', fontVariantNumeric: 'tabular-nums', letterSpacing: '0.02em' }}>{pad(value)}</span>
|
|
64
|
+
<span style={{ position: 'absolute', left: 0, right: 0, top: '50%', height: 2, background: 'oklch(0.4 0.08 60 / 0.12)' }} />
|
|
65
|
+
</span>
|
|
66
|
+
<span style={{ fontFamily: 'var(--font-display)', fontSize: 'clamp(11px, 2.6cqi, 14px)', letterSpacing: '0.1em', textTransform: 'uppercase', color: '#fff', WebkitTextStroke: '0.22em var(--wood-900)', paintOrder: 'stroke fill' }}>{unit}</span>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
{caption && <span aria-hidden="true" style={{ fontWeight: 700, fontSize: 15, color: 'var(--cream-50)', textShadow: '0 2px 0 oklch(0.3 0.06 55 / 0.6)' }}>{caption}</span>}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Live countdown that ticks every second. The board is the big wooden one for a front gate or a title screen. The chip is a ticking TimerChip for the HUD.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<Countdown to="2026-12-24T00:00:00" label="the party" caption="24 December" />
|
|
5
|
+
<Countdown variant="chip" to={deadline} label="the deadline" ended={<TimerChip icon="heart" time="Late entries welcome" />} />
|
|
6
|
+
<Countdown to={opensAt} title="Doors open in" icon="door-open" />
|
|
7
|
+
<Countdown to="2026-12-24T00:00:00" now={Date.parse('2026-12-20T09:30:00')} /> // frozen, for fixtures
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Give a `label` for what it counts down to. Screen readers hear "3 days, 4 hours and 12 minutes until the party". The seconds are left out so it isn't noisy.
|
|
11
|
+
- It renders nothing on the server and until the page hydrates, because the server's clock isn't the visitor's. Leave room for it if the layout would jump.
|
|
12
|
+
- When the time passes it shows `ended`, which is nothing by default. Swap in a message or another component.
|
|
13
|
+
- The board fills its container's width and scales its tiles to fit. One board per screen.
|
|
14
|
+
- The chip turns urgent in the last day. Change that with `urgentWithin`.
|
|
15
|
+
- Don't hard-code dates in components. Pass them in, so fixtures and real data stay separate.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HUD currency readout: dark inset pill, glyph overlapping the left edge, optional "+" buy button on the right.
|
|
5
|
+
* @startingPoint section="HUD" subtitle="Coins / gems / hearts readout with + button" viewport="700x160"
|
|
6
|
+
*/
|
|
7
|
+
export interface CurrencyPillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style'> {
|
|
8
|
+
/** Built-in glyph + tone. Default coin. */
|
|
9
|
+
kind?: 'coin' | 'gem' | 'ruby' | 'heart' | 'energy' | 'star' | 'key';
|
|
10
|
+
/** Formatted amount, e.g. "50,253k" or "3/3". */
|
|
11
|
+
value: React.ReactNode;
|
|
12
|
+
/** Custom glyph node replacing the built-in one (e.g. <GameIcon kind="chest" />). */
|
|
13
|
+
icon?: React.ReactNode;
|
|
14
|
+
/** When set, renders the teal "+" button and calls this on tap. */
|
|
15
|
+
onAdd?: () => void;
|
|
16
|
+
/** sm 28px / md 36px tall. */
|
|
17
|
+
size?: 'sm' | 'md';
|
|
18
|
+
/** dark = translucent brown over the world (default); light = cream, for inside panels. */
|
|
19
|
+
tone?: 'dark' | 'light';
|
|
20
|
+
minWidth?: number;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CurrencyPill(props: CurrencyPillProps): React.JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const KINDS = { coin: ['coin', 'var(--gold-500)', 'var(--gold-900)'], gem: ['sketch-logo', 'var(--blue-500)', 'var(--blue-900)'], ruby: ['sketch-logo', 'var(--coral-500)', 'var(--coral-900)'],
|
|
5
|
+
heart: ['heart', 'var(--coral-500)', 'var(--coral-900)'], energy: ['lightning', 'var(--teal-500)', 'var(--teal-900)'], star: ['star', 'var(--gold-500)', 'var(--gold-900)'], key: ['key', 'var(--gold-500)', 'var(--gold-900)'] };
|
|
6
|
+
const SIZES = { sm: [28, 14, 34, 22], md: [36, 18, 44, 28] }; // [pill height, font, icon, plus size]
|
|
7
|
+
|
|
8
|
+
export function CurrencyPill({ kind = 'coin', value, icon, onAdd, size = 'md', tone = 'dark', minWidth, style, ...rest }) {
|
|
9
|
+
const [down, setDown] = useState(false);
|
|
10
|
+
const [glyph, fill, stroke] = KINDS[kind] || KINDS.coin;
|
|
11
|
+
const [h, fs, ic, plus] = SIZES[size] || SIZES.md;
|
|
12
|
+
const dark = tone === 'dark';
|
|
13
|
+
return (
|
|
14
|
+
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', height: h, minWidth: minWidth || h * 3,
|
|
15
|
+
padding: '0 ' + (onAdd ? plus * 0.7 + 6 : 12) + 'px 0 ' + (ic * 0.72) + 'px', boxSizing: 'border-box', borderRadius: 'var(--radius-pill)',
|
|
16
|
+
background: dark ? 'var(--surface-hud)' : 'var(--cream-50)', border: dark ? '2px solid oklch(0.20 0.05 55 / 0.9)' : '2px solid var(--cream-300)',
|
|
17
|
+
boxShadow: dark ? 'inset 0 2px 4px oklch(0 0 0 / 0.35), 0 2px 0 oklch(1 0 0 / 0.15)' : 'inset 0 2px 3px oklch(0 0 0 / 0.08), 0 2px 0 var(--cream-300)',
|
|
18
|
+
fontFamily: 'var(--font-display)', fontSize: fs, color: dark ? '#fff' : 'var(--ink-900)', letterSpacing: '0.02em',
|
|
19
|
+
textShadow: dark ? '0 2px 0 oklch(0 0 0 / 0.35)' : 'none', ...style }} {...rest}>
|
|
20
|
+
<span aria-hidden="true" style={{ position: 'absolute', left: -ic * 0.28, top: '50%', transform: 'translateY(-50%)', width: ic, height: ic, display: 'grid', placeItems: 'center', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.25))' }}>
|
|
21
|
+
{icon || <i className={'ph-fill ph-' + glyph} style={{ fontSize: ic, lineHeight: 1, color: fill, WebkitTextStroke: (ic * 0.11) + 'px ' + stroke, paintOrder: 'stroke fill' }} />}
|
|
22
|
+
</span>
|
|
23
|
+
<span style={{ flex: 1, textAlign: 'center', paddingLeft: 2, whiteSpace: 'nowrap' }}>{value}</span>
|
|
24
|
+
{onAdd && (
|
|
25
|
+
<button type="button" aria-label="Add" onClick={onAdd} onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
|
|
26
|
+
style={{ position: 'absolute', right: -plus * 0.3, top: '50%', transform: 'translateY(-50%) translateY(' + (down ? 2 : 0) + 'px)', width: plus, height: plus, border: '2px solid #fff', borderRadius: 'var(--radius-sm)', padding: 0, cursor: 'pointer',
|
|
27
|
+
background: 'linear-gradient(180deg, var(--teal-300), var(--teal-500) 50%)', boxShadow: '0 ' + (down ? 1 : 3) + 'px 0 var(--teal-700)', color: '#fff', fontSize: plus * 0.6, lineHeight: 1, display: 'grid', placeItems: 'center',
|
|
28
|
+
transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)' }}>
|
|
29
|
+
<i className="ph-bold ph-plus" />
|
|
30
|
+
</button>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
HUD currency pill: the top-bar readout for coins, gems, hearts, energy; also used inline for a cost or a count ("3/3").
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<CurrencyPill kind="coin" value="50,253k" onAdd={() => openShop()} />
|
|
5
|
+
<CurrencyPill kind="gem" value={36} onAdd={openShop} />
|
|
6
|
+
<CurrencyPill kind="heart" value="5/5" size="sm" />
|
|
7
|
+
<CurrencyPill kind="energy" value={32} tone="light" />
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Top HUD: 2–3 pills max, right-aligned, size md, tone dark. Numbers are pre-formatted strings (50,253k, 1.2M).
|
|
11
|
+
- Inside panels use tone="light".
|
|
12
|
+
- onAdd only on purchasable currencies.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bar along the top of the map: chips on the left and a slot on the right, such as a sound toggle, that drops away on phones. It sits over the window and lets taps through to the map, except on what's inside it.
|
|
5
|
+
* @startingPoint section="HUD" subtitle="The map's top bar, with HudChips" viewport="700x180"
|
|
6
|
+
*/
|
|
7
|
+
export interface HudBarProps extends Omit<React.HTMLAttributes<HTMLElement>, 'children'> {
|
|
8
|
+
/** HudChips. On a narrow phone they wrap onto a second row instead of pushing the right side off screen. */
|
|
9
|
+
left?: React.ReactNode;
|
|
10
|
+
/** Hidden on phones. */
|
|
11
|
+
right?: React.ReactNode;
|
|
12
|
+
/** Hides the bar, for when something covers the map. */
|
|
13
|
+
hidden?: boolean;
|
|
14
|
+
/** Sits inside a positioned box instead of covering the window. */
|
|
15
|
+
contained?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function HudBar(props: HudBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
/* The bar along the top of the map: chips on the left, and a slot on the right (such as a sound toggle) that drops away on
|
|
5
|
+
phones. It sits over the window and lets taps through to the map, except on what's inside it. Set `contained` to place
|
|
6
|
+
it in a positioned box instead. Put `mp-chrome-arriving` or `mp-chrome-away` on a parent for the arrival and the climb. */
|
|
7
|
+
export function HudBar({ left, right, hidden = false, contained = false, ...rest }) {
|
|
8
|
+
return (
|
|
9
|
+
<header className={'mp-hud' + (contained ? ' mp-hud-contained' : '')} hidden={hidden} {...rest}>
|
|
10
|
+
<div className="mp-hud-left">{left}</div>
|
|
11
|
+
<div className="mp-hud-right">{right}</div>
|
|
12
|
+
</header>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
HUD bar: the strip across the top of the map.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<HudBar
|
|
5
|
+
left={<>
|
|
6
|
+
<HudChip icon="users-three" tone="teal" value="3/5" onClick={openPeople} aria-label="3 of 5 friends found. Open Your people" />
|
|
7
|
+
<HudChip icon="sparkle" tone="berry" value="1/3" fresh onClick={findNext} aria-label="1 of 3 surprises found, a new one just arrived. Show me the next one" />
|
|
8
|
+
</>}
|
|
9
|
+
right={<SoundToggle on={sound} onChange={setSound} size="sm" />} />
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- It covers the window and lets taps through to the map, so only the chips and the right slot take them. Set `contained` to place it in a positioned box.
|
|
13
|
+
- On a phone the right slot drops away, since sound stays one tap away in the menu. Its layout follows the window, not its box.
|
|
14
|
+
- Hide it with `hidden` while a sheet is open over the map.
|
|
15
|
+
- For the arrival (it drops in) and the climb (it steps aside), put `mp-chrome-arriving` or `mp-chrome-away` on a parent of the HUD, the toolbar and the dock.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/** True for a moment each time `count` goes up, so a chip can bump as something is found. */
|
|
4
|
+
export function useBump(count: number): boolean;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A count on the HUD, such as friends or surprises found: a dark pill with a big glyph overlapping its left edge. A tap does something about it. A coral dot says something new has arrived.
|
|
8
|
+
* @startingPoint section="HUD" subtitle="Friends and surprises counts" viewport="700x140"
|
|
9
|
+
*/
|
|
10
|
+
export interface HudChipProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
|
11
|
+
/** A Phosphor icon name. Default "users-three". */
|
|
12
|
+
icon?: string;
|
|
13
|
+
/** teal is steady. berry glints every few seconds, for surprises. */
|
|
14
|
+
tone?: 'teal' | 'berry';
|
|
15
|
+
/** The count, such as "3/5". */
|
|
16
|
+
value: string;
|
|
17
|
+
/** Bumps the chip, once. Use `useBump(count)`. */
|
|
18
|
+
bump?: boolean;
|
|
19
|
+
/** A coral dot: something new has arrived. */
|
|
20
|
+
fresh?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onClick?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function HudChip(props: HudChipProps): React.JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
|
+
import { CurrencyPill } from './CurrencyPill';
|
|
4
|
+
|
|
5
|
+
/* True for a moment each time `count` goes up, so a chip bumps as something is found. */
|
|
6
|
+
export function useBump(count) {
|
|
7
|
+
const [seen, setSeen] = useState(count);
|
|
8
|
+
const [bump, setBump] = useState(false);
|
|
9
|
+
if (count !== seen) {
|
|
10
|
+
setSeen(count);
|
|
11
|
+
if (count > seen) setBump(true);
|
|
12
|
+
}
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!bump) return;
|
|
15
|
+
const t = window.setTimeout(() => setBump(false), 560);
|
|
16
|
+
return () => window.clearTimeout(t);
|
|
17
|
+
}, [bump]);
|
|
18
|
+
return bump;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/* A count on the HUD, such as friends found or surprises found. A tap does something about it. Give it an aria-label
|
|
22
|
+
that says the count in words and what pressing it does. */
|
|
23
|
+
export function HudChip({ icon = 'users-three', tone = 'teal', value, bump = false, fresh = false, disabled = false, onClick, ...rest }) {
|
|
24
|
+
return (
|
|
25
|
+
<button type="button" className={'mp-hud-chip' + (bump ? ' mp-hud-bump' : '')} onClick={onClick} disabled={disabled} {...rest}>
|
|
26
|
+
<CurrencyPill size="md" value={value} icon={<i className={'ph-fill ph-' + icon + ' ' + (tone === 'berry' ? 'mp-hud-sparkle' : 'mp-hud-friends')} />} minWidth={86} />
|
|
27
|
+
{fresh && <span className="mp-hud-new" aria-hidden="true" />}
|
|
28
|
+
</button>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
HUD chip: a count that's also a shortcut.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
const bump = useBump(found);
|
|
5
|
+
<HudChip icon="users-three" value={`${found}/${total}`} bump={bump} onClick={openPeople} aria-label={`${found} of ${total} friends found. Open Your people`} />
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
- Always give it an `aria-label` that says the count in words and what a tap does. The visible "3/5" alone doesn't.
|
|
9
|
+
- `useBump(count)` returns true for half a second each time the count goes up, so the chip bumps and glows as something is found. It stays still when the count drops or stays the same.
|
|
10
|
+
- `fresh` adds a pulsing coral dot, for something that arrived after they started.
|
|
11
|
+
- Use `tone="berry"` for surprises. Its glyph glints now and then. With reduced motion, nothing moves.
|
|
12
|
+
- Disable it when there's nothing for a tap to do.
|
|
13
|
+
- It's a `CurrencyPill` inside a button, so the two always match.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Striped, bevelled progress bar in a dark inset track, XP, loading, boost timers, quest progress.
|
|
5
|
+
* @startingPoint section="HUD" subtitle="Striped XP / loading bar with cap + label" viewport="700x200"
|
|
6
|
+
*/
|
|
7
|
+
export interface ProgressBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style'> {
|
|
8
|
+
/** Current value; percent = value / max. */
|
|
9
|
+
value: number;
|
|
10
|
+
/** Default 1 (value is a 0–1 ratio). */
|
|
11
|
+
max?: number;
|
|
12
|
+
tone?: 'gold' | 'blue' | 'teal' | 'green' | 'coral' | 'berry';
|
|
13
|
+
/** sm 14 / md 22 / lg 30 px tall. */
|
|
14
|
+
size?: 'sm' | 'md' | 'lg';
|
|
15
|
+
/** Centered outlined label, e.g. "6/20" or "75%". */
|
|
16
|
+
label?: React.ReactNode;
|
|
17
|
+
/** Diagonal stripes on the fill (default true). */
|
|
18
|
+
striped?: boolean;
|
|
19
|
+
/** Node overlapping the left end (a level star badge, an avatar). */
|
|
20
|
+
cap?: React.ReactNode;
|
|
21
|
+
width?: number | string;
|
|
22
|
+
/** dark = brown inset over the world (default); light = cream, inside panels. */
|
|
23
|
+
track?: 'dark' | 'light';
|
|
24
|
+
style?: React.CSSProperties;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ProgressBar(props: ProgressBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const FILLS = {
|
|
5
|
+
gold: ['var(--gold-300)', 'var(--gold-500)', 'var(--gold-700)'],
|
|
6
|
+
blue: ['var(--blue-300)', 'var(--blue-500)', 'var(--blue-700)'],
|
|
7
|
+
teal: ['var(--teal-300)', 'var(--teal-500)', 'var(--teal-700)'],
|
|
8
|
+
green: ['var(--green-300)', 'var(--green-500)', 'var(--green-700)'],
|
|
9
|
+
coral: ['var(--coral-300)', 'var(--coral-500)', 'var(--coral-700)'],
|
|
10
|
+
berry: ['var(--berry-300)', 'var(--berry-500)', 'var(--berry-700)'],
|
|
11
|
+
};
|
|
12
|
+
const SIZES = { sm: [14, 11], md: [22, 14], lg: [30, 18] }; // [height, label size]
|
|
13
|
+
|
|
14
|
+
export function ProgressBar({ value = 0, max = 1, tone = 'gold', size = 'md', label, striped = true, cap, width, track = 'dark', style, ...rest }) {
|
|
15
|
+
const pct = Math.max(0, Math.min(100, (value / (max || 1)) * 100));
|
|
16
|
+
const [top, mid, shade] = FILLS[tone] || FILLS.gold;
|
|
17
|
+
const [h, fs] = SIZES[size] || SIZES.md;
|
|
18
|
+
const dark = track === 'dark';
|
|
19
|
+
return (
|
|
20
|
+
<div role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}
|
|
21
|
+
style={{ position: 'relative', display: 'flex', alignItems: 'center', width: width || '100%', minWidth: 80, ...style }} {...rest}>
|
|
22
|
+
{cap && <span style={{ position: 'relative', zIndex: 2, marginRight: -h * 0.5, flex: 'none', display: 'grid', placeItems: 'center' }}>{cap}</span>}
|
|
23
|
+
<div style={{ position: 'relative', flex: 1, height: h, borderRadius: 'var(--radius-pill)', overflow: 'hidden', boxSizing: 'border-box',
|
|
24
|
+
background: dark ? 'oklch(0.30 0.06 55 / 0.85)' : 'var(--cream-200)', border: '2px solid ' + (dark ? 'oklch(0.20 0.05 55 / 0.9)' : 'var(--cream-300)'),
|
|
25
|
+
boxShadow: 'inset 0 2px 4px oklch(0 0 0 / 0.35), 0 2px 0 oklch(1 0 0 / 0.15)' }}>
|
|
26
|
+
<div style={{ position: 'absolute', inset: 0, right: 'auto', width: pct + '%', minWidth: pct > 0 ? h : 0, borderRadius: 'var(--radius-pill)',
|
|
27
|
+
background: 'linear-gradient(180deg, ' + top + ' 0%, ' + mid + ' 55%, ' + shade + ' 100%)', boxShadow: 'inset 0 -3px 0 ' + shade + ', inset 0 2px 0 oklch(1 0 0 / 0.5)',
|
|
28
|
+
transition: 'width var(--dur-slow) var(--ease-out)' }}>
|
|
29
|
+
{striped && <div style={{ position: 'absolute', inset: 0, borderRadius: 'inherit', opacity: 0.35,
|
|
30
|
+
background: 'repeating-linear-gradient(-55deg, transparent 0 8px, oklch(1 0 0 / 0.55) 8px 14px)' }} />}
|
|
31
|
+
</div>
|
|
32
|
+
{label != null && (
|
|
33
|
+
<span style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', fontFamily: 'var(--font-display)', fontSize: fs, color: '#fff', lineHeight: 1,
|
|
34
|
+
WebkitTextStroke: (fs * 0.18) + 'px var(--ink-900)', paintOrder: 'stroke fill', letterSpacing: '0.03em' }}>{label}</span>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Striped progress bar: XP in the HUD, loading screens, boost/energy meters, quest and event progress.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<ProgressBar value={0.42} label="42%" />
|
|
5
|
+
<ProgressBar value={6} max={20} tone="green" label="6/20" size="lg" cap={<GameIcon kind="star" size={36} />} />
|
|
6
|
+
<ProgressBar value={0.7} tone="blue" size="sm" striped={false} />
|
|
7
|
+
<ProgressBar value={0.3} tone="teal" track="light" />
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Default gold fill on a dark track; green for quest/points, blue for water/energy-style meters, coral for hearts/health.
|
|
11
|
+
- Width transitions on change (420ms ease-out): animate by updating value, nothing else.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Small countdown chip (clock glyph + mm:ss) for boosts, offers, energy refills.
|
|
5
|
+
* @startingPoint section="HUD" subtitle="Countdown chip" viewport="700x120"
|
|
6
|
+
*/
|
|
7
|
+
export interface TimerChipProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'> {
|
|
8
|
+
/** Pre-formatted time, e.g. "00:02" or "3h 12m". */
|
|
9
|
+
time: React.ReactNode;
|
|
10
|
+
/** Phosphor glyph name. Default clock. */
|
|
11
|
+
icon?: string;
|
|
12
|
+
tone?: 'dark' | 'light';
|
|
13
|
+
size?: 'sm' | 'md';
|
|
14
|
+
/** Coral text + gentle float when time is nearly up. */
|
|
15
|
+
urgent?: boolean;
|
|
16
|
+
style?: React.CSSProperties;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TimerChip(props: TimerChipProps): React.JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
export function TimerChip({ time, icon = 'clock', tone = 'dark', size = 'md', urgent = false, style, ...rest }) {
|
|
5
|
+
const h = size === 'sm' ? 24 : 32; const fs = size === 'sm' ? 12 : 15;
|
|
6
|
+
const dark = tone === 'dark';
|
|
7
|
+
const alarm = dark ? 'var(--coral-300)' : 'var(--coral-700)'; // coral-300 is too light to read on cream
|
|
8
|
+
return (
|
|
9
|
+
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, height: h, padding: '0 12px 0 8px', borderRadius: 'var(--radius-pill)', boxSizing: 'border-box',
|
|
10
|
+
background: dark ? 'var(--surface-hud)' : 'var(--cream-50)', border: '2px solid ' + (dark ? 'oklch(0.20 0.05 55 / 0.9)' : 'var(--cream-300)'),
|
|
11
|
+
color: urgent ? alarm : (dark ? '#fff' : 'var(--ink-900)'), fontFamily: 'var(--font-display)', fontSize: fs, letterSpacing: '0.04em', fontVariantNumeric: 'tabular-nums',
|
|
12
|
+
textShadow: dark ? '0 2px 0 oklch(0 0 0 / 0.35)' : 'none', animation: urgent ? 'mp-float var(--dur-idle) ease-in-out infinite' : 'none', ...style }} {...rest}>
|
|
13
|
+
<i className={'ph-fill ph-' + icon} style={{ fontSize: fs + 4, lineHeight: 1, color: urgent ? alarm : (dark ? '#fff' : 'var(--ink-700)') }} />
|
|
14
|
+
<span>{time}</span>
|
|
15
|
+
</span>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Painted-style glyph: a Phosphor FILL icon with solid tone, dark outline behind the fill and a hard 2px drop.
|
|
5
|
+
* @startingPoint section="Icons" subtitle="Currency + world glyphs with outline treatment" viewport="700x220"
|
|
6
|
+
*/
|
|
7
|
+
export interface GameIconProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'style'> {
|
|
8
|
+
/** Semantic preset choosing glyph + tone: coin, coins, gem, ruby, heart, star, energy, key, gift, chest, trophy, crown, lock, clock, ticket, egg, map, compass, flag, leaf, tent, boat, mountains, footprints, play, check, x, plus, gear, shop, trash, sound, music, vibrate, bell, backpack, fire, binoculars, sparkle, quest, spin. */
|
|
9
|
+
kind?: 'coin' | 'coins' | 'gem' | 'ruby' | 'heart' | 'star' | 'energy' | 'key' | 'gift' | 'chest' | 'trophy' | 'crown' | 'lock' | 'clock' | 'ticket' | 'egg' | 'map' | 'compass' | 'flag' | 'leaf' | 'tent' | 'boat' | 'mountains' | 'footprints' | 'play' | 'check' | 'x' | 'plus' | 'gear' | 'shop' | 'trash' | 'sound' | 'music' | 'vibrate' | 'bell' | 'backpack' | 'fire' | 'binoculars' | 'sparkle' | 'quest' | 'spin';
|
|
10
|
+
/** Any Phosphor icon name (without the ph- prefix): overrides the preset glyph. */
|
|
11
|
+
name?: string;
|
|
12
|
+
/** Overrides the preset tone. */
|
|
13
|
+
tone?: 'gold' | 'coral' | 'blue' | 'teal' | 'green' | 'berry' | 'wood' | 'stone' | 'white' | 'ink';
|
|
14
|
+
/** Box size in px (glyph fills it). Default 28. */
|
|
15
|
+
size?: number;
|
|
16
|
+
/** Dark outline behind the fill. Default true. */
|
|
17
|
+
outline?: boolean;
|
|
18
|
+
/** Hard drop shadow. Default true. */
|
|
19
|
+
shadow?: boolean;
|
|
20
|
+
/** Phosphor weight. fill (default) for pictorial glyphs; bold for utility strokes (plus, x, check, carets). */
|
|
21
|
+
weight?: 'fill' | 'bold';
|
|
22
|
+
style?: React.CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function GameIcon(props: GameIconProps): React.JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
// Phosphor FILL glyph with the painted game-icon treatment: solid fill, dark outline painted behind, 2px drop.
|
|
5
|
+
const TONES = {
|
|
6
|
+
gold: ['var(--gold-500)', 'var(--gold-900)'],
|
|
7
|
+
coral: ['var(--coral-500)', 'var(--coral-900)'],
|
|
8
|
+
blue: ['var(--blue-500)', 'var(--blue-900)'],
|
|
9
|
+
teal: ['var(--teal-500)', 'var(--teal-900)'],
|
|
10
|
+
green: ['var(--green-500)', 'var(--green-900)'],
|
|
11
|
+
berry: ['var(--berry-500)', 'var(--berry-900)'],
|
|
12
|
+
wood: ['var(--wood-500)', 'var(--wood-900)'],
|
|
13
|
+
stone: ['var(--stone-300)', 'var(--stone-700)'],
|
|
14
|
+
white: ['#fff', 'var(--ink-900)'],
|
|
15
|
+
ink: ['var(--ink-900)', 'var(--ink-900)'],
|
|
16
|
+
};
|
|
17
|
+
// Semantic presets: glyph + tone
|
|
18
|
+
const KINDS = {
|
|
19
|
+
coin: ['coin', 'gold'], coins: ['coins', 'gold'], gem: ['sketch-logo', 'blue'], ruby: ['sketch-logo', 'coral'],
|
|
20
|
+
heart: ['heart', 'coral'], star: ['star', 'gold'], energy: ['lightning', 'teal'], key: ['key', 'gold'],
|
|
21
|
+
gift: ['gift', 'berry'], chest: ['treasure-chest', 'wood'], trophy: ['trophy', 'gold'], crown: ['crown', 'gold'],
|
|
22
|
+
lock: ['lock', 'stone'], clock: ['clock', 'white'], ticket: ['ticket', 'coral'], egg: ['egg', 'white'],
|
|
23
|
+
map: ['map-trifold', 'wood'], compass: ['compass', 'blue'], flag: ['flag', 'coral'], leaf: ['leaf', 'green'],
|
|
24
|
+
tent: ['tent', 'coral'], boat: ['boat', 'blue'], mountains: ['mountains', 'stone'], footprints: ['footprints', 'wood'],
|
|
25
|
+
play: ['play', 'white'], check: ['check', 'white', 'bold'], x: ['x', 'white', 'bold'], plus: ['plus', 'white', 'bold'], gear: ['gear', 'white'],
|
|
26
|
+
shop: ['storefront', 'coral'], trash: ['trash', 'stone'], sound: ['speaker-high', 'blue'], music: ['music-notes', 'blue'],
|
|
27
|
+
vibrate: ['vibrate', 'blue'], bell: ['bell', 'gold'], backpack: ['backpack', 'wood'], fire: ['fire', 'coral'],
|
|
28
|
+
binoculars: ['binoculars', 'wood'], sparkle: ['sparkle', 'gold'], quest: ['clipboard-text', 'coral'], spin: ['circle-half-tilt', 'berry'],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function GameIcon({ kind, name, tone, size = 28, outline = true, shadow = true, weight, style, ...rest }) {
|
|
32
|
+
const preset = KINDS[kind] || KINDS.coin;
|
|
33
|
+
const glyph = name || preset[0];
|
|
34
|
+
const w = weight || (name ? 'fill' : preset[2] || 'fill');
|
|
35
|
+
const [fill, stroke] = TONES[tone || preset[1]] || TONES.gold;
|
|
36
|
+
return (
|
|
37
|
+
<span aria-hidden="true" style={{ display: 'inline-grid', placeItems: 'center', width: size, height: size, flex: 'none', lineHeight: 1,
|
|
38
|
+
filter: shadow ? 'drop-shadow(0 ' + Math.max(1, Math.round(size * 0.07)) + 'px 0 oklch(0 0 0 / 0.25))' : 'none', ...style }} {...rest}>
|
|
39
|
+
<i className={'ph-' + w + ' ph-' + glyph} style={{ fontSize: size, lineHeight: 1, color: fill, display: 'block',
|
|
40
|
+
WebkitTextStroke: outline ? Math.max(1.5, size * 0.11) + 'px ' + stroke : '0', paintOrder: 'stroke fill' }} />
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Painted game glyph (Phosphor FILL + outline + drop) for any icon that stands on its own: on the world, on a panel, on a medal or a prize. Never emoji, and never the sticker look rebuilt in CSS.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<GameIcon kind="coin" size={32} />
|
|
5
|
+
<GameIcon kind="gem" />
|
|
6
|
+
<GameIcon kind="heart" size={20} />
|
|
7
|
+
<GameIcon name="anchor" tone="blue" size={40} />
|
|
8
|
+
<GameIcon kind="gear" outline={false} shadow={false} size={22} /> // plain white glyph inside a button
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Prefer a semantic kind (coin, gem, heart, star, energy, gift, lock…) so tones stay consistent across screens.
|
|
12
|
+
- Inside a component's icon slot (`Button`, `IconButton`, `Tag`, a level node), pass plain Phosphor (`<i className="ph-fill ph-star" />`) or a GameIcon with outline and shadow off. The component already carries the depth.
|
|
13
|
+
- Currency glyphs are always gold coin, blue gem, coral ruby/heart, teal energy.
|