lanre-ds 0.1.0 → 0.2.1
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/components/contributor/RadioCard.jsx +4 -8
- package/components/contributor/StepBar.jsx +3 -4
- package/components/map/PortraitMedallion.jsx +13 -35
- package/components/map/PortraitMedallion.prompt.md +1 -0
- package/package.json +1 -1
- package/readme.md +1 -1
- package/tokens/ui.css +80 -2
- package/tokens/utilities.css +6 -0
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import React
|
|
2
|
+
import React from 'react';
|
|
3
3
|
|
|
4
4
|
const DROP = '0 14px 24px oklch(0.22 0.05 60 / 0.3)';
|
|
5
5
|
const GLOW = '0 0 0 4px oklch(0.76 0.13 180 / 0.5), 0 0 28px oklch(0.76 0.13 180 / 0.6)';
|
|
6
6
|
|
|
7
|
-
export function RadioCard({ checked = false, onChange, onClick,
|
|
8
|
-
const [down, setDown] = useState(false);
|
|
9
|
-
// Your own handlers run alongside the card's, so passing one never breaks choosing or the press.
|
|
10
|
-
const press = (on, yours) => e => { setDown(on); if (yours) yours(e); };
|
|
7
|
+
export function RadioCard({ checked = false, onChange, onClick, title, description, media, style, className, ...rest }) {
|
|
11
8
|
return (
|
|
12
|
-
<button type="button" role="radio" aria-checked={checked} onClick={e => { if (onClick) onClick(e); if (onChange) onChange(true); }}
|
|
13
|
-
onPointerDown={press(true, onPointerDown)} onPointerUp={press(false, onPointerUp)} onPointerCancel={press(false, onPointerCancel)} onPointerLeave={press(false, onPointerLeave)}
|
|
9
|
+
<button type="button" role="radio" aria-checked={checked} className={'mp-radio-card' + (className ? ' ' + className : '')} onClick={e => { if (onClick) onClick(e); if (onChange) onChange(true); }}
|
|
14
10
|
style={{ appearance: 'none', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, padding: '22px 12px 16px', boxSizing: 'border-box',
|
|
15
11
|
borderRadius: 'var(--radius-lg)', border: '4px solid ' + (checked ? 'var(--teal-500)' : 'var(--cream-300)'),
|
|
16
12
|
background: checked ? 'linear-gradient(180deg, var(--teal-100), #fff)' : 'linear-gradient(180deg, #fff, var(--cream-100))',
|
|
17
13
|
boxShadow: checked ? '0 5px 0 var(--teal-700), ' + DROP + ', ' + GLOW : '0 5px 0 var(--cream-300), ' + DROP,
|
|
18
14
|
font: 'inherit', textAlign: 'center', cursor: 'pointer', WebkitTapHighlightColor: 'transparent',
|
|
19
|
-
transition: 'transform var(--dur-fast) var(--ease-out)',
|
|
15
|
+
transition: 'transform var(--dur-fast) var(--ease-out)', ...style }} {...rest}>
|
|
20
16
|
<span aria-hidden="true" style={{ position: 'absolute', right: 10, top: 10, width: 28, height: 28, boxSizing: 'border-box', borderRadius: '50%',
|
|
21
17
|
border: '3px solid ' + (checked ? 'var(--teal-500)' : 'var(--stone-500)'), background: checked ? 'var(--teal-500)' : '#fff', boxShadow: 'inset 0 2px 3px oklch(0 0 0 / 0.1)',
|
|
22
18
|
display: 'grid', placeItems: 'center', color: '#fff', fontSize: 14, animation: checked ? 'mp-radio-pop var(--dur-base) var(--ease-bounce) both' : undefined }}>
|
|
@@ -25,16 +25,15 @@ export function StepBar({ steps = [], current = 0, labels = false, style, ...res
|
|
|
25
25
|
<div style={{ position: 'relative' }}>
|
|
26
26
|
<span aria-hidden="true" style={{ position: 'absolute', top: 12, left: slot / 2, right: slot / 2, height: 12, borderRadius: 999,
|
|
27
27
|
background: 'var(--world-path)', boxShadow: '0 0 0 3px var(--world-path-edge), 0 4px 0 3px oklch(0.25 0.05 60 / 0.2)' }} />
|
|
28
|
-
<span aria-hidden="true" style={{ position: 'absolute', top: 16, left: slot / 2, width: slot * filled, height: 4, borderRadius: 999,
|
|
28
|
+
<span aria-hidden="true" className={advanced ? 'mp-trail-fill-grow' : undefined} style={{ position: 'absolute', top: 16, left: slot / 2, width: slot * filled, height: 4, borderRadius: 999,
|
|
29
29
|
background: 'var(--green-500)', transition: 'width var(--dur-slow) var(--ease-out)',
|
|
30
|
-
...(advanced ? { '--from': slot * Math.min(current - 1, filled) + 'px', '--to': slot * filled + 'px'
|
|
30
|
+
...(advanced ? { '--from': slot * Math.min(current - 1, filled) + 'px', '--to': slot * filled + 'px' } : null) }} />
|
|
31
31
|
<ol style={{ position: 'relative', listStyle: 'none', margin: 0, padding: 0, display: 'flex' }}>
|
|
32
32
|
{steps.map((label, i) => {
|
|
33
33
|
const done = i < current, cur = i === current, justDone = advanced && i === current - 1;
|
|
34
34
|
return (
|
|
35
35
|
<li key={i} aria-current={cur ? 'step' : undefined} style={{ width: slot, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
|
36
|
-
<span aria-hidden="true" className={'mp-step' + (done ? ' mp-step-done' : cur ? ' mp-step-current' : '')}
|
|
37
|
-
style={justDone ? { animation: 'mp-pop var(--dur-slow) var(--ease-bounce) 560ms both' } : undefined}>
|
|
36
|
+
<span aria-hidden="true" className={'mp-step' + (done ? ' mp-step-done' : cur ? ' mp-step-current' : '') + (justDone ? ' mp-step-pop' : '')}>
|
|
38
37
|
{done ? <i className="ph-bold ph-check" /> : i + 1}
|
|
39
38
|
{cur && <span style={{ position: 'absolute', inset: -4, borderRadius: '50%', animation: 'mp-pulse-ring 1.6s ease-out infinite' }} />}
|
|
40
39
|
</span>
|
|
@@ -1,51 +1,29 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import React
|
|
2
|
+
import React from 'react';
|
|
3
3
|
import { Avatar } from '../identity/Avatar';
|
|
4
4
|
|
|
5
|
+
/* A completed level on the map: the friend on a gold pedestal, with the level number on a plate underneath. Give it
|
|
6
|
+
onClick and it becomes a button that replays the level, with a press, a hover and a keyboard focus ring. The pedestal's
|
|
7
|
+
shadow and the number plate are the same at every size. */
|
|
5
8
|
export function PortraitMedallion({ name = '', seed, src, number, size = 84, pop = false, onClick, label, style, ...rest }) {
|
|
6
|
-
const [down, setDown] = useState(false);
|
|
7
|
-
const [hover, setHover] = useState(false);
|
|
8
9
|
const interactive = typeof onClick === 'function';
|
|
9
|
-
const pressed = interactive && down;
|
|
10
|
-
const depth = Math.max(4, Math.round(size / 12)), travel = depth - 2;
|
|
11
|
-
const plate = { h: Math.round(size * 0.36), min: Math.round(size * 0.43), fs: Math.round(size * 0.2) };
|
|
12
10
|
const who = name || 'a friend';
|
|
13
11
|
const accessibleName = label || (interactive
|
|
14
12
|
? (number != null ? 'Replay level ' + number + ', ' + who : 'Replay surprise from ' + who)
|
|
15
13
|
: (number != null ? 'Level ' + number + ', ' + who : name || 'A friend'));
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
background: 'radial-gradient(circle at 50% 30%, var(--gold-300), var(--gold-500) 60%, var(--gold-700))',
|
|
21
|
-
boxShadow: '0 ' + (pressed ? 2 : depth) + 'px 0 var(--gold-900), 0 ' + (pressed ? 6 : depth * 2) + 'px ' + (pressed ? 10 : 18) + 'px oklch(0.2 0.05 60 / 0.35), inset 0 3px 0 oklch(1 0 0 / 0.6)',
|
|
22
|
-
transform: pressed ? 'translateY(' + travel + 'px)' : 'none',
|
|
23
|
-
filter: hover && !pressed ? 'brightness(1.06)' : 'none',
|
|
24
|
-
transition: 'transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), filter var(--dur-fast)',
|
|
25
|
-
}}>
|
|
14
|
+
const medallion = (
|
|
15
|
+
<span className={'mp-medallion' + (pop ? ' mp-medallion-pop' : '')} style={{ '--m': size + 'px', ...(interactive ? null : style) }}
|
|
16
|
+
{...(interactive ? null : { role: 'img', 'aria-label': accessibleName, ...rest })}>
|
|
17
|
+
<span className="mp-medallion-ring">
|
|
26
18
|
<Avatar name={name} seed={seed} src={src} size={Math.round(size * 0.8)} alt="" />
|
|
27
19
|
</span>
|
|
28
|
-
{number != null &&
|
|
29
|
-
|
|
30
|
-
position: 'relative', zIndex: 1, marginTop: -Math.round(size * 0.17), minWidth: plate.min, height: plate.h, padding: '0 ' + Math.round(plate.h / 3) + 'px', boxSizing: 'border-box',
|
|
31
|
-
borderRadius: 'var(--radius-pill)', background: 'linear-gradient(180deg, #fff, var(--cream-100))', boxShadow: '0 3px 0 var(--cream-300), 0 6px 10px oklch(0 0 0 / 0.2)',
|
|
32
|
-
fontFamily: 'var(--font-display)', fontSize: plate.fs, lineHeight: plate.h + 'px', color: 'var(--ink-900)', textAlign: 'center',
|
|
33
|
-
transform: pressed ? 'translateY(' + (travel - 1) + 'px)' : 'none', transition: 'transform var(--dur-fast) var(--ease-out)',
|
|
34
|
-
}}>{number}</span>
|
|
35
|
-
)}
|
|
36
|
-
</>
|
|
20
|
+
{number != null && <span className="mp-medallion-number">{number}</span>}
|
|
21
|
+
</span>
|
|
37
22
|
);
|
|
38
|
-
|
|
39
|
-
position: 'relative', display: 'inline-grid', justifyItems: 'center', width: size,
|
|
40
|
-
animation: pop ? 'mp-medallion-pop 900ms var(--ease-out) both' : undefined,
|
|
41
|
-
};
|
|
42
|
-
if (!interactive) return <span role="img" aria-label={accessibleName} style={{ ...frame, ...style }} {...rest}>{body}</span>;
|
|
23
|
+
if (!interactive) return medallion;
|
|
43
24
|
return (
|
|
44
|
-
<button type="button" aria-label={accessibleName} onClick={onClick}
|
|
45
|
-
|
|
46
|
-
onPointerEnter={() => setHover(true)} onPointerLeave={() => { setDown(false); setHover(false); }}
|
|
47
|
-
style={{ appearance: 'none', background: 'none', border: 0, margin: 0, padding: 0, cursor: 'pointer', borderRadius: 'var(--radius-pill)', WebkitTapHighlightColor: 'transparent', ...frame, ...style }} {...rest}>
|
|
48
|
-
{body}
|
|
25
|
+
<button type="button" className="mp-medallion-button" aria-label={accessibleName} onClick={onClick} style={style} {...rest}>
|
|
26
|
+
{medallion}
|
|
49
27
|
</button>
|
|
50
28
|
);
|
|
51
29
|
}
|
|
@@ -11,3 +11,4 @@ Portrait medallion: what a completed level becomes on the map. The revealed frie
|
|
|
11
11
|
- Keep the replay label readable: it defaults to "Replay level 4, Ada Okafor". Pass `label` to change the wording, never to remove the name.
|
|
12
12
|
- `pop` plays the arrival once. Set it only for the level that was just finished.
|
|
13
13
|
- Same size as the `LevelNode` it replaces (84 by default), so the road doesn't shift.
|
|
14
|
+
- The pedestal's shadow and the number plate are the same at every size, so a small medallion for a discovery has the same bevel as a big one. It is a real button when it has `onClick`, with a hover, a press and a keyboard focus ring.
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -119,7 +119,7 @@ Source: the shared build brief (12 Sep 2026). The generic Lanre kit above is the
|
|
|
119
119
|
- `tokens/viewer.css`: the styles behind the viewer (`.mp-viewer`, `.mp-cabinet`, `.mp-keys`, `.mp-knob`, `.mp-tray`). It sets sizes for phones, iPad and desktop.
|
|
120
120
|
- **Package:** `package.json` makes the repo an installable package, `lanre-ds`: `import 'lanre-ds/styles.css'`, then `import { Button } from 'lanre-ds/components/buttons/Button'`. See the docs page *Install it in an app*. It is published on npm and licensed MIT.
|
|
121
121
|
- `tokens/fx.css`: the styles behind the effects and the boot screen (`.mp-confetti`, `.mp-sparkle-spot`, `.mp-sunburst-motion`, `.mp-loading`).
|
|
122
|
-
- `tokens/ui.css`: the styles behind the small pieces (`.mp-heading`, `.mp-sticker`, `.mp-notice`, `.mp-error-text`, `.mp-save-pill`, `.mp-save-alert`, `.mp-avatar-button`, `.mp-seg`, `.mp-recorder`).
|
|
122
|
+
- `tokens/ui.css`: the styles behind the small pieces (`.mp-heading`, `.mp-sticker`, `.mp-notice`, `.mp-error-text`, `.mp-save-pill`, `.mp-save-alert`, `.mp-avatar-button`, `.mp-seg`, `.mp-recorder`, `.mp-medallion`).
|
|
123
123
|
- `tokens/signs.css`: the styles behind the world signs (`.mp-gate`, `.mp-sign`, `.mp-world-pill`, `.mp-world-option`).
|
|
124
124
|
- `tokens/media.css`: the styles behind the media components (`.mp-words`, `.mp-album`, `.mp-cassette`, `.mp-film`, `.mp-shimmer`). They use container queries, so each piece fits the box it sits in.
|
|
125
125
|
- `tokens/worlds.css`, `tokens/gems.css`: world scopes and gem tones; iPad/phone layout tokens in `spacing.css`; memory/letter sizes in `typography.css`; focus ring + reduced motion in `utilities.css`; the night palette (`--night-*`, the summit letter and credits) and step-marker tokens (`--step-*`) in `colors.css`, with the `.mp-step` recipe in `utilities.css`.
|
package/tokens/ui.css
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Small interface pieces: a heading with its plate, a sticker, a notice and error text, the autosave pill and alert, an avatar
|
|
2
|
-
button with a pencil, segmented tabs,
|
|
3
|
-
SaveAlert, AvatarButton, SegmentedTabs and
|
|
2
|
+
button with a pencil, segmented tabs, the voice recorder card and the portrait medallion. Used by Heading, Sticker, Notice,
|
|
3
|
+
ErrorText, SavePill, SaveAlert, AvatarButton, SegmentedTabs, RecorderPanel and PortraitMedallion. */
|
|
4
4
|
|
|
5
5
|
/* The friend's avatar in the HUD, with a small pencil to say it can be changed. */
|
|
6
6
|
.mp-avatar-button {
|
|
@@ -352,3 +352,81 @@
|
|
|
352
352
|
background: var(--coral-500);
|
|
353
353
|
animation: mp-sparkle 1.2s infinite;
|
|
354
354
|
}
|
|
355
|
+
|
|
356
|
+
/* ---------- Portrait medallion ---------- */
|
|
357
|
+
|
|
358
|
+
/* The tappable wrapper: it takes the press, the hover and the keyboard focus for the medallion inside it. */
|
|
359
|
+
.mp-medallion-button {
|
|
360
|
+
appearance: none;
|
|
361
|
+
background: none;
|
|
362
|
+
border: 0;
|
|
363
|
+
padding: 0;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
-webkit-tap-highlight-color: transparent;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* Every tap answers back: the medallion presses down like a bevelled button. */
|
|
369
|
+
.mp-medallion-button:hover .mp-medallion-ring {
|
|
370
|
+
filter: brightness(1.06);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.mp-medallion-button:active .mp-medallion-ring {
|
|
374
|
+
transform: translateY(5px);
|
|
375
|
+
box-shadow: 0 2px 0 var(--gold-900), 0 6px 10px oklch(0.2 0.05 60 / 0.3), inset 0 3px 0 oklch(1 0 0 / 0.6);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.mp-medallion-button:active .mp-medallion-number {
|
|
379
|
+
transform: translateY(4px);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.mp-medallion-button:focus-visible {
|
|
383
|
+
outline: 4px solid #fff;
|
|
384
|
+
outline-offset: 4px;
|
|
385
|
+
border-radius: 999px;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* Set --m to the medallion's size. The shadow and the number plate stay the same at every size. */
|
|
389
|
+
.mp-medallion {
|
|
390
|
+
position: relative;
|
|
391
|
+
display: inline-grid;
|
|
392
|
+
justify-items: center;
|
|
393
|
+
width: var(--m);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.mp-medallion-ring {
|
|
397
|
+
display: grid;
|
|
398
|
+
place-items: center;
|
|
399
|
+
width: var(--m);
|
|
400
|
+
height: var(--m);
|
|
401
|
+
border-radius: 50%;
|
|
402
|
+
background: radial-gradient(circle at 50% 30%, var(--gold-300), var(--gold-500) 60%, var(--gold-700));
|
|
403
|
+
box-shadow: 0 7px 0 var(--gold-900), 0 14px 18px oklch(0.2 0.05 60 / 0.35), inset 0 3px 0 oklch(1 0 0 / 0.6);
|
|
404
|
+
transition: transform var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out), filter var(--dur-fast);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.mp-medallion-number {
|
|
408
|
+
margin-top: -14px;
|
|
409
|
+
min-width: 36px;
|
|
410
|
+
height: 30px;
|
|
411
|
+
padding: 0 10px;
|
|
412
|
+
border-radius: 999px;
|
|
413
|
+
background: linear-gradient(180deg, #fff, var(--cream-100));
|
|
414
|
+
box-shadow: 0 3px 0 var(--cream-300), 0 6px 10px oklch(0 0 0 / 0.2);
|
|
415
|
+
font-family: var(--font-display);
|
|
416
|
+
font-size: 17px;
|
|
417
|
+
line-height: 30px;
|
|
418
|
+
color: var(--ink-900);
|
|
419
|
+
text-align: center;
|
|
420
|
+
z-index: 1;
|
|
421
|
+
transition: transform var(--dur-fast) var(--ease-out);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.mp-medallion-pop {
|
|
425
|
+
animation: mp-medallion-pop 900ms var(--ease-out) both;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
@media (prefers-reduced-motion: reduce) {
|
|
429
|
+
.mp-medallion-pop {
|
|
430
|
+
animation: none;
|
|
431
|
+
}
|
|
432
|
+
}
|
package/tokens/utilities.css
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
.mp-step-done { --step-highlight: var(--step-done-highlight); --step-fill: var(--step-done-fill); --step-edge: var(--step-done-edge); --step-outline: var(--step-done-outline); }
|
|
30
30
|
.mp-step-current { --step-highlight: var(--step-current-highlight); --step-fill: var(--step-current-fill); --step-edge: var(--step-current-edge); --step-outline: var(--step-current-outline); transform: translateY(-3px); }
|
|
31
31
|
.mp-step-lg { width: 40px; height: 25px; font-size: 17px; transform: none; }
|
|
32
|
+
/* StepBar's celebration: the fill grows, then the new marker pops. Classes, so reduced motion can switch them off (the rule below only shortens animations, and these wait before they start). */
|
|
33
|
+
.mp-trail-fill-grow { animation: mp-trail-grow var(--dur-slow) var(--ease-out) 260ms both; }
|
|
34
|
+
.mp-step-pop { animation: mp-pop var(--dur-slow) var(--ease-bounce) 560ms both; }
|
|
35
|
+
@media (prefers-reduced-motion: reduce) { .mp-trail-fill-grow, .mp-step-pop { animation: none; } }
|
|
36
|
+
/* RadioCard presses down like a bevelled button (CSS, so a key press does it too). */
|
|
37
|
+
.mp-radio-card:active { transform: translateY(3px); }
|
|
32
38
|
@media (prefers-reduced-motion: reduce) {
|
|
33
39
|
*, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
|
|
34
40
|
}
|