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,13 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The card that stands in when a photo, video or voice note didn't load, with a Try again button.
|
|
5
|
+
* @startingPoint section="Media" subtitle="When media didn't load" viewport="700x140"
|
|
6
|
+
*/
|
|
7
|
+
export interface UnavailableProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
/** What failed, in a word or two: "photo", "video", "voice note", "set of photos". Reads "This photo didn't load." */
|
|
9
|
+
what?: string;
|
|
10
|
+
onRetry?: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Unavailable(props: UnavailableProps): React.JSX.Element;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Button } from '../buttons/Button';
|
|
4
|
+
|
|
5
|
+
export function Unavailable({ what = 'photo', onRetry, ...rest }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="mp-unavailable" {...rest}>
|
|
8
|
+
<i className="ph-fill ph-cloud-warning" aria-hidden="true" />
|
|
9
|
+
<span>This {what} didn't load.</span>
|
|
10
|
+
<Button variant="ghost" size="sm" onClick={onRetry}>Try again</Button>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Unavailable card: what shows when a photo, video or voice note didn't load. The media components use it on their own when they have no source, and you can use it anywhere media can fail.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<Unavailable what="photo" onRetry={refresh} />
|
|
5
|
+
<Unavailable what="set of photos" onRetry={refresh} />
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
- Say what failed in a word or two. It reads "This photo didn't load." with a Try again button.
|
|
9
|
+
- `onRetry` is where you fetch a fresh link. Links to private media often expire.
|
|
10
|
+
- It's a flat cream card, not an error. Nothing here has gone wrong for the player, so keep the wording calm.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/** Messages shorter than this sit big in the middle and type themselves in. Longer ones read like a letter. */
|
|
4
|
+
export const SHORT_CHARS: number;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A written memory, filling its box. A few words sit big in the middle and type themselves in once. Longer text lays out as paragraphs that scroll, with a "Scroll to keep reading" hint.
|
|
8
|
+
* @startingPoint section="Media" subtitle="A written memory in a screen" viewport="700x420"
|
|
9
|
+
*/
|
|
10
|
+
export interface WordsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
11
|
+
/** The text. A blank line starts a new paragraph. */
|
|
12
|
+
text: string;
|
|
13
|
+
/** True once it has typed in, so it doesn't type again. Short messages only. */
|
|
14
|
+
typed?: boolean;
|
|
15
|
+
/** Called when the last character is in, or straight away for long text. */
|
|
16
|
+
onTyped?: () => void;
|
|
17
|
+
/** Accessible name for the scrolling text. Default "Message". */
|
|
18
|
+
label?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Words(props: WordsProps): React.JSX.Element;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { reduceMotion } from './shared.js';
|
|
4
|
+
|
|
5
|
+
/* A few words sit big in the middle of the box; anything longer reads like a letter. */
|
|
6
|
+
export const SHORT_CHARS = 180;
|
|
7
|
+
|
|
8
|
+
export function Words({ text = '', typed = false, onTyped, label = 'Message', ...rest }) {
|
|
9
|
+
const clean = text.trim();
|
|
10
|
+
const short = clean.length < SHORT_CHARS;
|
|
11
|
+
const [shown, setShown] = useState(() => (short && !typed && !reduceMotion() ? 0 : clean.length));
|
|
12
|
+
const [more, setMore] = useState(false);
|
|
13
|
+
const box = useRef(null);
|
|
14
|
+
const done = useRef(onTyped);
|
|
15
|
+
useEffect(() => { done.current = onTyped; });
|
|
16
|
+
|
|
17
|
+
// Short messages type themselves in, once. The rest of the text is already laid out, so nothing jumps.
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (shown >= clean.length) { if (done.current) done.current(); return; }
|
|
20
|
+
const id = window.setTimeout(() => setShown(n => Math.min(clean.length, n + 2)), 28);
|
|
21
|
+
return () => window.clearTimeout(id);
|
|
22
|
+
}, [shown, clean.length]);
|
|
23
|
+
|
|
24
|
+
const measure = useCallback(() => {
|
|
25
|
+
const el = box.current;
|
|
26
|
+
if (el) setMore(el.scrollHeight - el.scrollTop - el.clientHeight > 24);
|
|
27
|
+
}, []);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const id = window.requestAnimationFrame(measure);
|
|
30
|
+
window.addEventListener('resize', measure);
|
|
31
|
+
return () => { window.cancelAnimationFrame(id); window.removeEventListener('resize', measure); };
|
|
32
|
+
}, [measure]);
|
|
33
|
+
|
|
34
|
+
const paragraphs = clean.split(/\n{2,}/).map(p => p.trim()).filter(Boolean);
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<div ref={box} className={'mp-words' + (short ? ' mp-words-short' : '')} onScroll={measure} tabIndex={0} role="group" aria-label={label} {...rest}>
|
|
38
|
+
<div className="mp-words-inner">
|
|
39
|
+
{short ? (
|
|
40
|
+
<p>
|
|
41
|
+
<span>{clean.slice(0, shown)}</span>
|
|
42
|
+
{shown < clean.length && (
|
|
43
|
+
<>
|
|
44
|
+
<span className="mp-words-cursor" aria-hidden="true" />
|
|
45
|
+
<span className="mp-words-unwritten">{clean.slice(shown)}</span>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</p>
|
|
49
|
+
) : (
|
|
50
|
+
paragraphs.map((p, i) => <p key={i}>{p}</p>)
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{more && <div className="mp-words-more" aria-hidden="true">Scroll to keep reading</div>}
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Words: a written memory filling its box. Put it in a box with a height, such as a screen or a sheet.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<div style={{ position: 'relative', height: 480 }}>
|
|
5
|
+
<Words text={memory.text} typed={seen} onTyped={() => setSeen(true)} label="Their message" />
|
|
6
|
+
</div>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- Under 180 characters it sits big in the middle and types itself in, once. Pass `typed` once it has, so coming back to it doesn't type again. With reduced motion it's already there.
|
|
10
|
+
- Anything longer lays out as paragraphs at a reading size, and scrolls. A "Scroll to keep reading" hint shows until you reach the end. A blank line in `text` starts a new paragraph.
|
|
11
|
+
- The box is focusable, so keyboard users can scroll it. Give it a `label` that names the message neutrally, never the sender.
|
|
12
|
+
- For a written letter on a parchment card, use `MemoryText`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
/* Helpers shared by the media components. */
|
|
5
|
+
|
|
6
|
+
export const reduceMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
7
|
+
export const clock = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
|
|
8
|
+
// Browser recordings (webm) often report an Infinity duration until played through; treat that as unknown.
|
|
9
|
+
export const finite = d => (Number.isFinite(d) && d > 0 ? d : 0);
|
|
10
|
+
|
|
11
|
+
/* Whether a photo or video has arrived, for a skeleton while it is on its way. Keyed by the URL, so a fresh link
|
|
12
|
+
(or the next photo) starts loading again. A photo already in the cache counts as arrived straight away. */
|
|
13
|
+
export function useLoaded(src) {
|
|
14
|
+
const [arrived, setArrived] = useState(null);
|
|
15
|
+
const loaded = !!src && arrived === src;
|
|
16
|
+
const done = useCallback(() => setArrived(src ?? null), [src]);
|
|
17
|
+
const checkImage = useCallback(el => { if (el && el.complete && el.naturalWidth > 0) setArrived(src ?? null); }, [src]);
|
|
18
|
+
return { loaded, done, checkImage };
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal shell: puts a Panel over the scrim, closes on Escape, locks page scroll, keeps keyboard focus inside and hands it back on close, and leaves with a short fade.
|
|
5
|
+
* @startingPoint section="Panels" subtitle="Modal shell for panels over the scrim" viewport="700x420"
|
|
6
|
+
*/
|
|
7
|
+
export interface ModalProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style' | 'children'> {
|
|
8
|
+
/** Accessible name for the dialog, usually the panel title. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Called once the exit fade has finished. Unmount the modal here. */
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
/** dim = the scrim over the world, blurred by --scrim-blur (default), dusk = the evening ground, world = the ground of `world`. */
|
|
13
|
+
tone?: 'dim' | 'dusk' | 'world';
|
|
14
|
+
/** World id for data-world, so the panel and a world tone pick up that world's colours. */
|
|
15
|
+
world?: 'mango_grove' | 'small_chops' | 'good_energy' | 'japan' | 'summit';
|
|
16
|
+
/** Close when the scrim around the panel is tapped. Default false. */
|
|
17
|
+
closeOnScrim?: boolean;
|
|
18
|
+
/** Stacking order. Default 45. */
|
|
19
|
+
zIndex?: number;
|
|
20
|
+
/** The panel. Pass a function to get `close`, which plays the exit fade before calling onClose. */
|
|
21
|
+
children?: React.ReactNode | ((close: () => void) => React.ReactNode);
|
|
22
|
+
style?: React.CSSProperties;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Modal(props: ModalProps): React.JSX.Element;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const GROUND = { dim: 'var(--bg-scrim)', dusk: 'var(--bg-dusk)', world: 'var(--world-bg)' };
|
|
5
|
+
const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), [tabindex]';
|
|
6
|
+
const tabbable = el => !el.disabled && el.getAttribute('tabindex') !== '-1' && !el.closest('[inert]') && el.getClientRects().length > 0;
|
|
7
|
+
const open = []; // stack of open modals: only the top one answers Escape and Tab
|
|
8
|
+
let locks = 0, savedOverflow = ''; // one scroll lock shared by every open modal, so the order they close in doesn't matter
|
|
9
|
+
|
|
10
|
+
export function Modal({ label, onClose, tone = 'dim', world, closeOnScrim = false, zIndex = 45, children, style, ...rest }) {
|
|
11
|
+
const box = useRef(null), timer = useRef(0), pressedScrim = useRef(false);
|
|
12
|
+
const [closing, setClosing] = useState(false);
|
|
13
|
+
const close = useCallback(() => {
|
|
14
|
+
if (closing) return;
|
|
15
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return onClose && onClose();
|
|
16
|
+
setClosing(true);
|
|
17
|
+
timer.current = setTimeout(() => onClose && onClose(), 240);
|
|
18
|
+
}, [onClose, closing]);
|
|
19
|
+
const closeRef = useRef(close);
|
|
20
|
+
useEffect(() => { closeRef.current = close; });
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const id = {}, opener = document.activeElement, root = document.documentElement;
|
|
24
|
+
open.push(id);
|
|
25
|
+
if (locks++ === 0) { savedOverflow = root.style.overflow; root.style.overflow = 'hidden'; }
|
|
26
|
+
if (box.current) box.current.focus();
|
|
27
|
+
const onKey = e => {
|
|
28
|
+
if (open[open.length - 1] !== id) return;
|
|
29
|
+
if (e.key === 'Escape') { e.preventDefault(); closeRef.current(); return; }
|
|
30
|
+
if (e.key !== 'Tab' || !box.current) return;
|
|
31
|
+
const items = Array.from(box.current.querySelectorAll(FOCUSABLE)).filter(tabbable);
|
|
32
|
+
if (!items.length) { e.preventDefault(); return; }
|
|
33
|
+
const first = items[0], last = items[items.length - 1], at = document.activeElement;
|
|
34
|
+
if (e.shiftKey && (at === first || !items.includes(at))) { e.preventDefault(); last.focus(); }
|
|
35
|
+
else if (!e.shiftKey && (at === last || !items.includes(at))) { e.preventDefault(); first.focus(); }
|
|
36
|
+
};
|
|
37
|
+
document.addEventListener('keydown', onKey);
|
|
38
|
+
return () => {
|
|
39
|
+
clearTimeout(timer.current); // a fade still running must not call onClose after we're gone
|
|
40
|
+
document.removeEventListener('keydown', onKey);
|
|
41
|
+
open.splice(open.indexOf(id), 1);
|
|
42
|
+
if (--locks === 0) root.style.overflow = savedOverflow;
|
|
43
|
+
if (opener && typeof opener.focus === 'function' && document.contains(opener)) opener.focus();
|
|
44
|
+
};
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
// Only a tap that both starts and ends on the scrim closes it, so a text selection that ends there doesn't.
|
|
48
|
+
const onScrim = e => e.target === e.currentTarget || e.target === box.current;
|
|
49
|
+
// Only the see-through scrim blurs the world behind it. The dusk and world grounds are opaque.
|
|
50
|
+
const blur = GROUND[tone] && tone !== 'dim' ? undefined : 'blur(var(--scrim-blur))';
|
|
51
|
+
return (
|
|
52
|
+
<div role="dialog" aria-modal="true" aria-label={label} data-world={world}
|
|
53
|
+
onPointerDown={closeOnScrim ? e => { pressedScrim.current = onScrim(e); } : undefined}
|
|
54
|
+
onClick={closeOnScrim ? e => { if (pressedScrim.current && onScrim(e)) close(); pressedScrim.current = false; } : undefined}
|
|
55
|
+
style={{
|
|
56
|
+
position: 'fixed', inset: 0, zIndex, overflowY: 'auto', background: GROUND[tone] || GROUND.dim, backdropFilter: blur, WebkitBackdropFilter: blur,
|
|
57
|
+
animation: closing ? 'mp-sheet-out 240ms var(--ease-in-out) both' : 'mp-sheet-in 260ms var(--ease-out) both',
|
|
58
|
+
pointerEvents: closing ? 'none' : undefined, ...style,
|
|
59
|
+
}} {...rest}>
|
|
60
|
+
<div ref={box} tabIndex={-1} style={{
|
|
61
|
+
minHeight: '100%', boxSizing: 'border-box', display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', placeItems: 'center',
|
|
62
|
+
padding: 'max(64px, env(safe-area-inset-top)) 16px max(40px, env(safe-area-inset-bottom))', outline: 'none',
|
|
63
|
+
}}>
|
|
64
|
+
{typeof children === 'function' ? children(close) : children}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Modal shell: the behaviour around a Panel. It puts the panel over the scrim, closes on Escape, locks page scroll, keeps keyboard focus inside, gives focus back to whatever opened it, and fades out.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
{open && (
|
|
5
|
+
<Modal label="Settings" onClose={() => setOpen(false)}>
|
|
6
|
+
{close => <Panel title="Settings" onClose={close}>…rows…</Panel>}
|
|
7
|
+
</Modal>
|
|
8
|
+
)}
|
|
9
|
+
<Modal label="A note from the author" world="small_chops" tone="world" onClose={closeNote}>
|
|
10
|
+
{close => <Panel title="A Note" accent="teal" footer={<Button variant="confirm" onClick={close}>OK</Button>}>…</Panel>}
|
|
11
|
+
</Modal>
|
|
12
|
+
<Modal label="The summit" world="summit" tone="dusk" onClose={closeSummit}>…</Modal>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- Pass a function as the child and wire its `close` to the panel's close and footer buttons. `close` plays the exit fade, then calls `onClose`. Calling `onClose` yourself skips the fade.
|
|
16
|
+
- Always give a `label`. It's what screen readers announce.
|
|
17
|
+
- One panel per modal. Panels stay centred and never go edge to edge.
|
|
18
|
+
- The scrim is `--bg-scrim`, and it blurs the world behind it by `--scrim-blur`. Only the scrim blurs, never the panel. Use `tone="world"` with a `world` for world notes, and `tone="dusk"` for the summit. Both grounds are opaque, so they don't blur.
|
|
19
|
+
- Tapping the scrim does nothing unless you set `closeOnScrim`. The recipient’s sheets leave it off, so a stray tap on the iPad doesn't close a memory.
|
|
20
|
+
- Reduced motion closes straight away, with no fade.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal card with a chunky game header straddling its top edge, banner plate, celebration ribbon (tails + stars) or wooden sign (nails + leaves): bevelled body, optional close button + footer.
|
|
5
|
+
* @startingPoint section="Panels" subtitle="Dialog card with banner, ribbon or sign header" viewport="700x440"
|
|
6
|
+
*/
|
|
7
|
+
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style' | 'title'> {
|
|
8
|
+
title?: React.ReactNode;
|
|
9
|
+
/** Small cream pill sitting on top of the plate, e.g. "Level 23". */
|
|
10
|
+
eyebrow?: React.ReactNode;
|
|
11
|
+
/** banner = tinted plate (settings, shop, level, reveal); ribbon = gold plate with folded tails + stars (level complete); sign = wooden plank with leaves (daily reward, world events); none. 'tab' and 'bush' are accepted as aliases. */
|
|
12
|
+
header?: 'banner' | 'ribbon' | 'sign' | 'none' | 'tab' | 'bush';
|
|
13
|
+
/** Banner colour. Default blue (gold for ribbon). Use teal for the recipient’s continue/confirm dialogs. */
|
|
14
|
+
accent?: 'blue' | 'gold' | 'teal' | 'green' | 'berry' | 'coral';
|
|
15
|
+
/** cream (default parchment), wood (plank body, thick brown border), sky (white → pale blue). */
|
|
16
|
+
tone?: 'cream' | 'wood' | 'sky';
|
|
17
|
+
/** Renders the coral × button on the top-right corner. */
|
|
18
|
+
onClose?: () => void;
|
|
19
|
+
/** Default 340 (mobile panel). */
|
|
20
|
+
width?: number | string;
|
|
21
|
+
/** Body padding. Default 20. */
|
|
22
|
+
padding?: number;
|
|
23
|
+
children?: React.ReactNode;
|
|
24
|
+
/** Centered button row at the bottom. */
|
|
25
|
+
footer?: React.ReactNode;
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Panel(props: PanelProps): React.JSX.Element;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const BODY = {
|
|
5
|
+
cream: { bg: 'linear-gradient(180deg, var(--cream-50), var(--cream-100))', border: 'var(--cream-300)', bw: 4 },
|
|
6
|
+
wood: { bg: 'var(--wood-100)', border: 'var(--wood-500)', bw: 6 },
|
|
7
|
+
sky: { bg: 'linear-gradient(180deg, #fff, var(--blue-100))', border: 'var(--blue-300)', bw: 4 },
|
|
8
|
+
};
|
|
9
|
+
// banner accents: [highlight, face, edge, outline]
|
|
10
|
+
const ACCENTS = {
|
|
11
|
+
blue: ['var(--blue-300)', 'var(--blue-500)', 'var(--blue-700)', 'var(--blue-900)'],
|
|
12
|
+
gold: ['var(--gold-300)', 'var(--gold-500)', 'var(--gold-700)', 'var(--gold-900)'],
|
|
13
|
+
teal: ['var(--teal-300)', 'var(--teal-500)', 'var(--teal-700)', 'var(--teal-900)'],
|
|
14
|
+
green: ['var(--green-300)', 'var(--green-500)', 'var(--green-700)', 'var(--green-900)'],
|
|
15
|
+
berry: ['var(--berry-300)', 'var(--berry-500)', 'var(--berry-700)', 'var(--berry-900)'],
|
|
16
|
+
coral: ['var(--coral-300)', 'var(--coral-500)', 'var(--coral-700)', 'var(--coral-900)'],
|
|
17
|
+
};
|
|
18
|
+
const ALIAS = { tab: 'banner', bush: 'sign' }; // older names still work
|
|
19
|
+
const H = 62; // header plate height
|
|
20
|
+
|
|
21
|
+
function titleStyle(title, stroke, fill, scale = 1) {
|
|
22
|
+
const len = typeof title === 'string' ? title.length : 10;
|
|
23
|
+
const fs = len > 18 ? 18 : len > 12 ? 22 : 26;
|
|
24
|
+
const cq = (len > 18 ? 6 : len > 12 ? 7.5 : 9.5) * scale;
|
|
25
|
+
return { position: 'relative', fontFamily: 'var(--font-display)', fontSize: 'min(' + fs + 'px, ' + cq + 'cqw)', lineHeight: 1.05, letterSpacing: '0.04em', textTransform: 'uppercase', color: fill, WebkitTextStroke: '0.16em ' + stroke, paintOrder: 'stroke fill', textShadow: '0 3px 0 oklch(0 0 0 / 0.28)', textAlign: 'center', whiteSpace: 'nowrap' };
|
|
26
|
+
}
|
|
27
|
+
const Eyebrow = ({ children, outline }) => (
|
|
28
|
+
<span style={{ position: 'absolute', top: -13, left: '50%', transform: 'translateX(-50%)', zIndex: 3, height: 26, padding: '0 12px', borderRadius: 999, boxSizing: 'border-box', background: 'var(--cream-50)', border: '3px solid ' + outline, boxShadow: '0 2px 0 ' + outline, display: 'grid', placeItems: 'center', fontFamily: 'var(--font-display)', fontSize: 12, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-900)', whiteSpace: 'nowrap' }}>{children}</span>
|
|
29
|
+
);
|
|
30
|
+
// The wrap shrinks to the plate's width, so tails and leaves hang off the plate's real edges. Title size follows panel width (cqw) so plates never outgrow narrow panels.
|
|
31
|
+
const wrap = { position: 'absolute', top: 0, left: '50%', transform: 'translateX(-50%)', zIndex: 2, minWidth: '56%', height: H, display: 'inline-grid', alignItems: 'center', justifyItems: 'stretch' };
|
|
32
|
+
|
|
33
|
+
// Banner: chunky plate straddling the panel's top edge. ribbon adds folded tails + stars.
|
|
34
|
+
function Banner({ title, eyebrow, accent, ribbon, room }) {
|
|
35
|
+
const [hi, face, edge, deep] = ACCENTS[accent] || ACCENTS.blue;
|
|
36
|
+
const clip = side => side === 'left' ? 'polygon(0 0, 100% 0, 100% 100%, 0 100%, 26% 50%)' : 'polygon(0 0, 100% 0, 74% 50%, 100% 100%, 0 100%)';
|
|
37
|
+
const Tail = ({ side }) => (
|
|
38
|
+
<span aria-hidden="true" style={{ position: 'absolute', top: 22, left: side === 'left' ? -30 : 'auto', right: side === 'right' ? -30 : 'auto', width: 66, height: H - 28, transform: 'rotate(' + (side === 'left' ? -6 : 6) + 'deg)', filter: 'drop-shadow(0 5px 0 ' + deep + ')', zIndex: 0 }}>
|
|
39
|
+
<span style={{ position: 'absolute', inset: 0, clipPath: clip(side), background: deep }} />
|
|
40
|
+
<span style={{ position: 'absolute', inset: 4, clipPath: clip(side), background: 'linear-gradient(' + (side === 'left' ? '90deg' : '270deg') + ', ' + edge + ' 0%, ' + face + ' 80%)' }} />
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
return (
|
|
44
|
+
<div style={wrap}>
|
|
45
|
+
{ribbon && <Tail side="left" />}
|
|
46
|
+
{ribbon && <Tail side="right" />}
|
|
47
|
+
<div style={{ position: 'relative', height: H, padding: '0 26px', boxSizing: 'border-box', borderRadius: 20, border: '4px solid ' + deep,
|
|
48
|
+
background: 'linear-gradient(180deg, ' + hi + ' 0%, ' + face + ' 48%)', boxShadow: '0 6px 0 ' + edge + ', 0 12px 20px oklch(0.2 0.05 60 / 0.35), inset 0 3px 0 oklch(1 0 0 / 0.5)', display: 'grid', placeItems: 'center' }}>
|
|
49
|
+
<span aria-hidden="true" style={{ position: 'absolute', top: 6, left: 14, right: 14, height: 9, borderRadius: 999, background: 'oklch(1 0 0 / 0.35)' }} />
|
|
50
|
+
<span style={titleStyle(title, deep, '#fff', room)}>{title}</span>
|
|
51
|
+
{ribbon && ['left', 'right'].map(s => (
|
|
52
|
+
<i key={s} aria-hidden="true" className="ph-fill ph-star" style={{ position: 'absolute', top: -18, left: s === 'left' ? -8 : 'auto', right: s === 'right' ? -8 : 'auto', fontSize: 32, lineHeight: 1, color: 'var(--gold-300)', WebkitTextStroke: '3px var(--gold-900)', paintOrder: 'stroke fill', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.25))', transform: 'rotate(' + (s === 'left' ? -18 : 18) + 'deg)' }} />
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
{eyebrow && <Eyebrow outline={deep}>{eyebrow}</Eyebrow>}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Sign: wooden plank with nails and leaves, world events, daily reward.
|
|
61
|
+
function Sign({ title, eyebrow, room }) {
|
|
62
|
+
const leaf = (side, rot, top, size, key) => (
|
|
63
|
+
<i key={key} aria-hidden="true" className="ph-fill ph-leaf" style={{ position: 'absolute', top, left: side === 'left' ? -12 : 'auto', right: side === 'right' ? -12 : 'auto', fontSize: size, lineHeight: 1, color: 'var(--green-500)', WebkitTextStroke: '3px var(--green-900)', paintOrder: 'stroke fill', transform: 'rotate(' + rot + 'deg)', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.25))', zIndex: 0 }} />
|
|
64
|
+
);
|
|
65
|
+
return (
|
|
66
|
+
<div style={{ ...wrap, minWidth: '62%' }}>
|
|
67
|
+
{leaf('left', -35, -14, 36, 'a')}{leaf('right', 35, -14, 36, 'b')}{leaf('left', -115, 28, 28, 'c')}{leaf('right', 115, 28, 28, 'd')}
|
|
68
|
+
<div style={{ position: 'relative', height: H, padding: '0 30px', boxSizing: 'border-box', borderRadius: 14, border: '4px solid var(--wood-700)',
|
|
69
|
+
background: 'repeating-linear-gradient(180deg, transparent 0 9px, oklch(0 0 0 / 0.06) 9px 11px), linear-gradient(180deg, var(--wood-300), var(--wood-500))',
|
|
70
|
+
boxShadow: '0 6px 0 var(--wood-900), 0 12px 20px oklch(0.2 0.05 60 / 0.35), inset 0 3px 0 oklch(1 0 0 / 0.3)', display: 'grid', placeItems: 'center' }}>
|
|
71
|
+
{['left', 'right'].map(s => <span key={s} aria-hidden="true" style={{ position: 'absolute', top: 7, left: s === 'left' ? 8 : 'auto', right: s === 'right' ? 8 : 'auto', width: 9, height: 9, borderRadius: '50%', background: 'radial-gradient(circle at 35% 30%, var(--stone-100), var(--stone-500))', boxShadow: '0 1px 0 var(--wood-900)' }} />)}
|
|
72
|
+
<span style={titleStyle(title, 'var(--wood-900)', 'var(--cream-100)', room)}>{title}</span>
|
|
73
|
+
</div>
|
|
74
|
+
{eyebrow && <Eyebrow outline="var(--wood-900)">{eyebrow}</Eyebrow>}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CloseButton({ onClick }) {
|
|
80
|
+
const [down, setDown] = useState(false);
|
|
81
|
+
return (
|
|
82
|
+
<button type="button" aria-label="Close" onClick={onClick} onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
|
|
83
|
+
style={{ position: 'absolute', top: -10, right: -10, width: 40, height: 40, borderRadius: '50%', border: '3px solid #fff', padding: 0, cursor: 'pointer', zIndex: 3,
|
|
84
|
+
background: 'linear-gradient(180deg, var(--coral-300), var(--coral-500) 50%)', boxShadow: '0 ' + (down ? 1 : 4) + 'px 0 var(--coral-700), 0 6px 10px oklch(0 0 0 / 0.25)',
|
|
85
|
+
transform: 'translateY(' + (down ? 3 : 0) + 'px)', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)', color: '#fff', fontSize: 20, display: 'grid', placeItems: 'center', lineHeight: 1 }}>
|
|
86
|
+
<i className="ph-bold ph-x" />
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function Panel({ title, eyebrow, header = 'banner', accent, tone = 'cream', onClose, width = 340, padding = 20, children, footer, style, ...rest }) {
|
|
92
|
+
const kind = ALIAS[header] || header;
|
|
93
|
+
const room = onClose ? 0.84 : 1; // shrink the title a little so the plate never runs under the × button
|
|
94
|
+
const t = BODY[tone] || BODY.cream;
|
|
95
|
+
const has = kind !== 'none' && title != null;
|
|
96
|
+
const overlap = has ? Math.round(H * 0.55) : 0; // part of the plate rises above the panel edge
|
|
97
|
+
const acc = accent || (kind === 'ribbon' ? 'gold' : 'blue');
|
|
98
|
+
return (
|
|
99
|
+
<div style={{ position: 'relative', width, maxWidth: '100%', boxSizing: 'border-box', paddingTop: overlap, containerType: 'inline-size', filter: 'drop-shadow(0 18px 28px oklch(0.22 0.05 60 / 0.35))', ...style }} {...rest}>
|
|
100
|
+
{has && (kind === 'sign' ? <Sign title={title} eyebrow={eyebrow} room={room} /> : <Banner title={title} eyebrow={eyebrow} accent={acc} ribbon={kind === 'ribbon'} room={room} />)}
|
|
101
|
+
<div style={{ position: 'relative', background: t.bg, border: t.bw + 'px solid ' + t.border, borderRadius: 'var(--radius-xl)', boxSizing: 'border-box',
|
|
102
|
+
boxShadow: 'inset 0 3px 0 oklch(1 0 0 / 0.7), inset 0 -4px 0 oklch(0 0 0 / 0.06)', paddingTop: has ? H - overlap : 0 }}>
|
|
103
|
+
{onClose && <CloseButton onClick={onClose} />}
|
|
104
|
+
<div style={{ padding, paddingTop: has ? padding - 4 : padding }}>{children}</div>
|
|
105
|
+
{footer && <div style={{ padding: '0 ' + padding + 'px ' + padding + 'px', display: 'flex', justifyContent: 'center', gap: 12 }}>{footer}</div>}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Dialog panel: every modal (settings, shop, level complete, daily reward, offers) is a Panel over a scrim; one panel per screen.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<Panel title="Settings" onClose={close}>…rows…</Panel>
|
|
5
|
+
<Panel title="Complete" eyebrow="Level 23" header="ribbon" footer={<Button variant="confirm" size="lg">OK</Button>}>…</Panel>
|
|
6
|
+
<Panel title="Daily Reward" header="sign" tone="wood" footer={<Button size="lg">Claim</Button>}>…</Panel>
|
|
7
|
+
<Panel title="Who sent this?" accent="teal" footer={<Button variant="confirm" size="lg">Continue</Button>}>…</Panel>
|
|
8
|
+
<Panel header="none" tone="sky" width={300}>…</Panel>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- Header choice by mood: banner = utility and level dialogs (blue; teal accent in the recipient’s flow), ribbon = celebration, sign = world / event / daily reward. The plate straddles the top edge, leave ~34px above the panel free.
|
|
12
|
+
- Put the primary action in footer (one lg button, max two).
|
|
13
|
+
- Place inside a fixed scrim: background var(--bg-scrim), centered.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Friend-facing leaderboard row: rank (ties share, shown as "=3"), avatar, name, gem + tier text, score. Skipped quiz = pearl, unranked, no score.
|
|
5
|
+
* @startingPoint section="Quiz" subtitle="Ranked rows, ties, you, skipped" viewport="700x300"
|
|
6
|
+
*/
|
|
7
|
+
export interface LeaderboardRowProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style'> {
|
|
8
|
+
rank?: number;
|
|
9
|
+
/** Shares rank with neighbours → renders "=N". */
|
|
10
|
+
tied?: boolean;
|
|
11
|
+
name: string;
|
|
12
|
+
seed?: string;
|
|
13
|
+
portrait?: string;
|
|
14
|
+
gem?: 'amber' | 'emerald' | 'amethyst' | 'pearl';
|
|
15
|
+
tier?: 1 | 2 | 3;
|
|
16
|
+
score?: number;
|
|
17
|
+
/** Default 8. */
|
|
18
|
+
total?: number;
|
|
19
|
+
/** Highlights the viewer's own row. */
|
|
20
|
+
me?: boolean;
|
|
21
|
+
/** Quiz skipped: pearl keepsake, "-" rank, no score. */
|
|
22
|
+
skipped?: boolean;
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function LeaderboardRow(props: LeaderboardRowProps): React.JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Avatar } from '../identity/Avatar';
|
|
4
|
+
import { Gemstone, GEM_NAMES } from '../identity/Gemstone';
|
|
5
|
+
|
|
6
|
+
const ROMAN = ['', 'I', 'II', 'III'];
|
|
7
|
+
|
|
8
|
+
export function LeaderboardRow({ rank, tied = false, name, seed, portrait, gem = 'amber', tier = 1, score, total = 8, me = false, skipped = false, style, ...rest }) {
|
|
9
|
+
const g = skipped ? 'pearl' : gem;
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 64, padding: '8px 14px 8px 10px', borderRadius: 'var(--radius-lg)', boxSizing: 'border-box',
|
|
12
|
+
background: me ? 'linear-gradient(180deg, var(--teal-100), #fff)' : 'linear-gradient(180deg, #fff, var(--cream-100))',
|
|
13
|
+
border: '3px solid ' + (me ? 'var(--teal-300)' : 'var(--cream-300)'), boxShadow: '0 3px 0 ' + (me ? 'var(--teal-500)' : 'var(--cream-300)'), fontFamily: 'var(--font-ui)', ...style }} {...rest}>
|
|
14
|
+
<span aria-label={skipped ? 'Unranked' : 'Rank ' + rank + (tied ? ', tied' : '')} style={{ width: 40, textAlign: 'center', fontFamily: 'var(--font-display)', fontSize: skipped ? 16 : 20, color: skipped ? 'var(--ink-500)' : 'var(--ink-900)', flex: 'none' }}>{skipped ? '-' : (tied ? '=' : '') + rank}</span>
|
|
15
|
+
<Avatar name={name} seed={seed} src={portrait} size={40} />
|
|
16
|
+
<span style={{ flex: 1, minWidth: 0, display: 'grid', gap: 1 }}>
|
|
17
|
+
<span style={{ fontWeight: 700, fontSize: 16, color: 'var(--ink-900)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{name}{me && <span style={{ marginLeft: 8, fontFamily: 'var(--font-display)', fontSize: 11, color: 'var(--teal-700)', letterSpacing: '0.08em' }}>YOU</span>}</span>
|
|
18
|
+
<span style={{ fontWeight: 600, fontSize: 12, color: 'var(--ink-500)' }}>{skipped ? 'Pearl keepsake · unranked' : GEM_NAMES[gem] + ' ' + ROMAN[tier]}</span>
|
|
19
|
+
</span>
|
|
20
|
+
<Gemstone gem={g} tier={tier} size={34} animate={false} />
|
|
21
|
+
<span style={{ width: 44, textAlign: 'right', fontFamily: 'var(--font-display)', fontSize: 18, color: 'var(--ink-900)', fontVariantNumeric: 'tabular-nums', flex: 'none' }}>{skipped ? '' : score + '/' + total}</span>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Leaderboard row: FRIENDS ONLY (after their quiz). The recipient never sees this list.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<LeaderboardRow rank={1} name="Ada Okafor" seed="c_01" gem="amethyst" tier={3} score={8} />
|
|
5
|
+
<LeaderboardRow rank={3} tied name="Tunde B." seed="c_22" gem="emerald" tier={2} score={4} me />
|
|
6
|
+
<LeaderboardRow name="Kemi A." seed="c_07" skipped />
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- Ties share rank (=3, =3). Skipped players sit at the bottom, unranked, never as 0/8.
|
|
10
|
+
- Tone celebrates participation: no "winner" language, no crowns.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inventory / merge-grid cell: translucent dark square over the world with filled, empty, locked and add states; selected glows teal.
|
|
5
|
+
* @startingPoint section="Rewards" subtitle="Inventory grid cells with counts + selection" viewport="700x200"
|
|
6
|
+
*/
|
|
7
|
+
export interface ItemCellProps extends Omit<React.HTMLAttributes<HTMLElement>, 'style' | 'content'> {
|
|
8
|
+
state?: 'empty' | 'filled' | 'locked' | 'add';
|
|
9
|
+
/** The item glyph/illustration shown when filled. */
|
|
10
|
+
content?: React.ReactNode;
|
|
11
|
+
/** Level / quantity shown bottom-right when filled. */
|
|
12
|
+
count?: number | string;
|
|
13
|
+
/** Teal border + glow. */
|
|
14
|
+
selected?: boolean;
|
|
15
|
+
/** Ghosted silhouette (undiscovered item). */
|
|
16
|
+
dim?: boolean;
|
|
17
|
+
/** Default 72. */
|
|
18
|
+
size?: number;
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
label?: string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ItemCell(props: ItemCellProps): React.JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export function ItemCell({ state = 'empty', content, count, selected = false, dim = false, size = 72, onClick, label, style, ...rest }) {
|
|
5
|
+
const [down, setDown] = useState(false);
|
|
6
|
+
const clickable = !!onClick && state !== 'locked';
|
|
7
|
+
const Tag = clickable ? 'button' : 'div';
|
|
8
|
+
return (
|
|
9
|
+
<Tag type={clickable ? 'button' : undefined} onClick={clickable ? onClick : undefined} aria-label={label} onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
|
|
10
|
+
style={{ appearance: 'none', margin: 0, padding: 0, position: 'relative', display: 'grid', placeItems: 'center', width: size, height: size, flex: 'none', boxSizing: 'border-box', cursor: clickable ? 'pointer' : 'default',
|
|
11
|
+
borderRadius: 'var(--radius-md)', background: selected ? 'var(--surface-cell-active)' : 'var(--surface-cell)',
|
|
12
|
+
border: '3px solid ' + (selected ? 'var(--teal-300)' : 'oklch(1 0 0 / 0.08)'),
|
|
13
|
+
boxShadow: selected ? '0 0 0 3px oklch(0.88 0.09 184 / 0.5), 0 0 18px oklch(0.76 0.13 180 / 0.8), inset 0 2px 6px oklch(0 0 0 / 0.25)' : 'inset 0 2px 6px oklch(0 0 0 / 0.25)',
|
|
14
|
+
transform: down && clickable ? 'scale(0.96)' : 'scale(1)', transition: 'transform var(--dur-fast), box-shadow var(--dur-base)', WebkitTapHighlightColor: 'transparent', ...style }} {...rest}>
|
|
15
|
+
{state === 'filled' && <span style={{ display: 'grid', placeItems: 'center', opacity: dim ? 0.45 : 1, filter: dim ? 'grayscale(0.6)' : 'none' }}>{content}</span>}
|
|
16
|
+
{state === 'locked' && <i aria-hidden="true" className="ph-fill ph-lock" style={{ fontSize: size * 0.4, color: 'oklch(1 0 0 / 0.35)', lineHeight: 1 }} />}
|
|
17
|
+
{state === 'add' && <i aria-hidden="true" className="ph-bold ph-plus" style={{ fontSize: size * 0.5, color: 'var(--gold-500)', WebkitTextStroke: (size * 0.05) + 'px var(--gold-900)', paintOrder: 'stroke fill', lineHeight: 1, filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.25))' }} />}
|
|
18
|
+
{count != null && state === 'filled' && (
|
|
19
|
+
<span style={{ position: 'absolute', right: 6, bottom: 4, fontFamily: 'var(--font-display)', fontSize: Math.round(size * 0.2), lineHeight: 1, color: '#fff',
|
|
20
|
+
WebkitTextStroke: (size * 0.035) + 'px var(--ink-900)', paintOrder: 'stroke fill', opacity: dim ? 0.6 : 1 }}>{count}</span>
|
|
21
|
+
)}
|
|
22
|
+
</Tag>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Inventory cell: grids of collectibles, merge items, party slots. 4 columns on mobile, 6px gaps, laid over grass.
|
|
2
|
+
|
|
3
|
+
```jsx
|
|
4
|
+
<ItemCell state="filled" content={<GameIcon kind="egg" size={44} />} count={1} selected onClick={select} />
|
|
5
|
+
<ItemCell state="filled" content={<GameIcon kind="leaf" size={44} />} count={4} dim />
|
|
6
|
+
<ItemCell state="empty" />
|
|
7
|
+
<ItemCell state="locked" />
|
|
8
|
+
<ItemCell state="add" onClick={buySlot} label="Buy slot" />
|
|
9
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reward calendar cell (Day N + prize) with claimed / available / locked states; featured renders a wide blue banner card.
|
|
5
|
+
* @startingPoint section="Rewards" subtitle="Daily reward cells + featured day" viewport="700x260"
|
|
6
|
+
*/
|
|
7
|
+
export interface RewardCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'style' | 'title'> {
|
|
8
|
+
/** Strip label, e.g. "Day 3". */
|
|
9
|
+
title: React.ReactNode;
|
|
10
|
+
/** Prize glyph, e.g. <GameIcon kind="gem" size={40} />. */
|
|
11
|
+
icon?: React.ReactNode;
|
|
12
|
+
/** Prize amount text, e.g. "+50". */
|
|
13
|
+
amount?: React.ReactNode;
|
|
14
|
+
/** claimed = green overlay + check; available = gold glow, floating icon, tappable; locked = desaturated + lock. */
|
|
15
|
+
state?: 'claimed' | 'available' | 'locked';
|
|
16
|
+
/** Wide blue banner (the grand prize row). */
|
|
17
|
+
featured?: boolean;
|
|
18
|
+
/** Called when an available card is tapped. */
|
|
19
|
+
onClick?: () => void;
|
|
20
|
+
width?: number | string;
|
|
21
|
+
style?: React.CSSProperties;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function RewardCard(props: RewardCardProps): React.JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export function RewardCard({ title, icon, amount, state = 'available', featured = false, onClick, width, style, ...rest }) {
|
|
5
|
+
const [down, setDown] = useState(false);
|
|
6
|
+
const claimed = state === 'claimed', locked = state === 'locked', avail = state === 'available';
|
|
7
|
+
const clickable = avail && onClick;
|
|
8
|
+
const border = featured ? 'var(--blue-500)' : 'var(--wood-500)';
|
|
9
|
+
const strip = featured ? 'var(--blue-700)' : 'var(--wood-700)';
|
|
10
|
+
const body = featured ? 'linear-gradient(180deg, var(--blue-100), var(--blue-300))' : 'linear-gradient(180deg, var(--cream-50), var(--wood-100))';
|
|
11
|
+
return (
|
|
12
|
+
<div role={clickable ? 'button' : undefined} tabIndex={clickable ? 0 : undefined} onClick={clickable ? onClick : undefined}
|
|
13
|
+
onPointerDown={() => setDown(true)} onPointerUp={() => setDown(false)} onPointerLeave={() => setDown(false)}
|
|
14
|
+
style={{ position: 'relative', display: 'flex', flexDirection: featured ? 'row' : 'column', alignItems: 'center', width: width || (featured ? '100%' : 96), boxSizing: 'border-box',
|
|
15
|
+
borderRadius: 'var(--radius-md)', border: '3px solid ' + border, background: body, overflow: 'hidden', cursor: clickable ? 'pointer' : 'default',
|
|
16
|
+
boxShadow: '0 ' + (down && clickable ? 1 : 4) + 'px 0 ' + strip + ', 0 6px 10px oklch(0.2 0.05 60 / 0.2)' + (avail ? ', 0 0 0 3px oklch(0.92 0.14 90 / 0.9), 0 0 18px oklch(0.86 0.17 85 / 0.7)' : ''),
|
|
17
|
+
transform: 'translateY(' + (down && clickable ? 3 : 0) + 'px)', transition: 'transform var(--dur-fast), box-shadow var(--dur-fast)',
|
|
18
|
+
filter: locked ? 'saturate(0.35) brightness(0.92)' : 'none', ...style }} {...rest}>
|
|
19
|
+
<div style={{ alignSelf: 'stretch', flex: featured ? '0 0 auto' : undefined, display: 'grid', placeItems: 'center', padding: featured ? '0 14px' : '5px 6px', minWidth: featured ? 72 : undefined,
|
|
20
|
+
background: strip, color: '#fff', fontFamily: 'var(--font-display)', fontSize: 13, letterSpacing: '0.06em', textTransform: 'uppercase', textShadow: '0 1px 0 oklch(0 0 0 / 0.3)', whiteSpace: 'nowrap' }}>{title}</div>
|
|
21
|
+
<div style={{ display: 'flex', flexDirection: featured ? 'row' : 'column', alignItems: 'center', justifyContent: 'center', gap: featured ? 12 : 4, padding: featured ? '10px 14px' : '10px 8px 10px', flex: 1 }}>
|
|
22
|
+
<span style={{ display: 'grid', placeItems: 'center', animation: avail ? 'mp-float var(--dur-idle) ease-in-out infinite' : 'none' }}>{icon}</span>
|
|
23
|
+
{amount != null && <span style={{ fontFamily: 'var(--font-display)', fontSize: featured ? 22 : 16, lineHeight: 1, color: featured ? '#fff' : 'var(--ink-900)',
|
|
24
|
+
WebkitTextStroke: featured ? '3px var(--blue-700)' : '0', paintOrder: 'stroke fill', letterSpacing: '0.02em' }}>{amount}</span>}
|
|
25
|
+
</div>
|
|
26
|
+
{claimed && (
|
|
27
|
+
<div aria-label="Claimed" style={{ position: 'absolute', inset: 0, background: 'oklch(0.58 0.16 140 / 0.45)', display: 'grid', placeItems: 'center' }}>
|
|
28
|
+
<i className="ph-bold ph-check" style={{ fontSize: 40, color: '#fff', WebkitTextStroke: '3px var(--green-700)', paintOrder: 'stroke fill', filter: 'drop-shadow(0 2px 0 oklch(0 0 0 / 0.25))' }} />
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
{locked && <i aria-hidden="true" className="ph-fill ph-lock" style={{ position: 'absolute', top: 30, right: 6, fontSize: 18, color: 'var(--stone-300)', WebkitTextStroke: '2px var(--stone-700)', paintOrder: 'stroke fill' }} />}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|