@hadoku/task-ui-components 4.23.4 → 4.23.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/components/AvatarBuilder.d.ts +28 -0
  2. package/dist/components/AvatarBuilder.d.ts.map +1 -0
  3. package/dist/components/AvatarBuilder.js +674 -0
  4. package/dist/components/AvatarBuilder.js.map +1 -0
  5. package/dist/components/AvatarDialog.d.ts +24 -0
  6. package/dist/components/AvatarDialog.d.ts.map +1 -0
  7. package/dist/components/AvatarDialog.js +95 -0
  8. package/dist/components/AvatarDialog.js.map +1 -0
  9. package/dist/components/AvatarPicker.d.ts +19 -10
  10. package/dist/components/AvatarPicker.d.ts.map +1 -1
  11. package/dist/components/AvatarPicker.js +19 -71
  12. package/dist/components/AvatarPicker.js.map +1 -1
  13. package/dist/components/ConnectedSettings.d.ts +1 -1
  14. package/dist/components/ConnectedSettings.d.ts.map +1 -1
  15. package/dist/components/ConnectedSettings.js +17 -6
  16. package/dist/components/ConnectedSettings.js.map +1 -1
  17. package/dist/lib/avatarData.d.ts +27 -0
  18. package/dist/lib/avatarData.d.ts.map +1 -0
  19. package/dist/lib/avatarData.js +23 -0
  20. package/dist/lib/avatarData.js.map +1 -0
  21. package/dist/vendor/wolffm/prefs-client/avatar.js +7 -1
  22. package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
  23. package/dist/vendor/wolffm/prefs-client/cosmetics.js +491 -0
  24. package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -0
  25. package/package.json +2 -1
  26. package/src/components/AvatarBuilder.tsx +835 -0
  27. package/src/components/AvatarDialog.tsx +118 -0
  28. package/src/components/AvatarPicker.tsx +39 -121
  29. package/src/components/ConnectedSettings.tsx +21 -13
  30. package/src/lib/avatarData.ts +40 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * AvatarBuilder — the body of AvatarDialog: the person's avatar and what they
3
+ * wear with it, with a live preview.
4
+ *
5
+ * Layout (operator's pick, 2026-11-01): a preview column on the left (the
6
+ * avatar large, with the name, and a chat line as others see it), and on the
7
+ * right tabs for the Picture (AvatarPicker) and each cosmetic slot. Narrow
8
+ * screens stack the two.
9
+ *
10
+ * Cosmetics (`@wolffm/prefs-client/cosmetics`, bundled at build like the avatar
11
+ * client): earned items equip on click; items not yet earned show how to earn
12
+ * them and can be tried on in the preview, which saves nothing. An overlay
13
+ * image can be placed: drag it on the big preview, turn it with the top handle,
14
+ * resize it with the corner handle, or use the sliders. Every move goes through
15
+ * `fitPlacement`, the same function prefs-api runs before it stores one, so the
16
+ * preview never shows a spot the server would refuse.
17
+ *
18
+ * Styles are inline for the reason AvatarPicker's are: apps compile this
19
+ * package's CSS at their own build. Tokens only; the cosmetics' own colours come
20
+ * from the person's character colour (and, for overlay images, the art).
21
+ */
22
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
23
+ import { Icon } from '@wolffm/themes'
24
+ import type { AvatarSettings, LoadoutUpdate } from '@wolffm/prefs-client/avatar'
25
+ import {
26
+ COSMETICS,
27
+ COSMETICS_BY_ID,
28
+ COSMETIC_SLOTS,
29
+ PLACEMENT_DEFAULT,
30
+ PLACEMENT_LIMITS,
31
+ avatarHtml,
32
+ cleanLoadout,
33
+ ensureCosmeticsStyles,
34
+ fitPlacement,
35
+ messageHtml,
36
+ nameHtml,
37
+ placementTransform,
38
+ wornCosmetics,
39
+ type AvatarCosmetics,
40
+ type CosmeticSlot,
41
+ type Loadout,
42
+ type OverlayArt,
43
+ type Placement
44
+ } from '@wolffm/prefs-client/cosmetics'
45
+ import { AvatarPicker } from './AvatarPicker'
46
+ import { AVATAR_EVENT } from '../lib/avatarEvent'
47
+ import {
48
+ avatarClient,
49
+ avatarErrorText,
50
+ fetchCharacters,
51
+ portraitUrl,
52
+ type CharacterSet
53
+ } from '../lib/avatarData'
54
+
55
+ type Tab = 'picture' | CosmeticSlot
56
+
57
+ const SLOT_LABEL: Record<CosmeticSlot, string> = {
58
+ effect: 'Effect',
59
+ gradient: 'Gradient',
60
+ font: 'Font',
61
+ overlay: 'Overlay',
62
+ message: 'Message'
63
+ }
64
+
65
+ const SLIDERS = [
66
+ ['x', 'Left/right', '%'],
67
+ ['y', 'Up/down', '%'],
68
+ ['r', 'Rotate', '°'],
69
+ ['s', 'Size', '%']
70
+ ] as const
71
+
72
+ const S = {
73
+ body: { display: 'grid', gap: 16, alignItems: 'start' },
74
+ side: { display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 },
75
+ stage: {
76
+ position: 'relative',
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ alignItems: 'center',
80
+ gap: 12,
81
+ padding: '48px 12px 18px',
82
+ borderRadius: 10,
83
+ background: 'var(--color-bg-alt)',
84
+ border: '1px solid var(--color-border)',
85
+ touchAction: 'none',
86
+ userSelect: 'none'
87
+ },
88
+ tryOn: {
89
+ position: 'absolute',
90
+ left: 8,
91
+ right: 8,
92
+ top: 8,
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: 6,
96
+ padding: '4px 8px',
97
+ borderRadius: 6,
98
+ background: 'var(--color-primary-bg)',
99
+ color: 'var(--color-on-primary-bg)',
100
+ fontSize: '0.72rem'
101
+ },
102
+ linkBtn: {
103
+ border: 0,
104
+ background: 'none',
105
+ color: 'inherit',
106
+ textDecoration: 'underline',
107
+ cursor: 'pointer',
108
+ fontSize: 'inherit',
109
+ padding: 0,
110
+ flex: 'none'
111
+ },
112
+ chat: {
113
+ display: 'flex',
114
+ flexDirection: 'column',
115
+ gap: 10,
116
+ padding: 12,
117
+ borderRadius: 10,
118
+ background: 'var(--color-bg)',
119
+ border: '1px solid var(--color-border)'
120
+ },
121
+ chatHead: {
122
+ display: 'flex',
123
+ justifyContent: 'space-between',
124
+ alignItems: 'center',
125
+ fontSize: '0.72rem',
126
+ color: 'var(--color-text-muted)'
127
+ },
128
+ msg: { display: 'flex', gap: 10, alignItems: 'flex-start' },
129
+ who: { minHeight: 22, display: 'flex', alignItems: 'center' },
130
+ line: { fontSize: '0.85rem', marginTop: 2 },
131
+ place: {
132
+ display: 'flex',
133
+ flexDirection: 'column',
134
+ gap: 8,
135
+ padding: 12,
136
+ borderRadius: 10,
137
+ border: '1px solid var(--color-border)'
138
+ },
139
+ placeHead: { display: 'flex', flexDirection: 'column', gap: 2, fontSize: '0.8rem' },
140
+ hint: { fontSize: '0.72rem', color: 'var(--color-text-muted)' },
141
+ slider: {
142
+ display: 'grid',
143
+ gridTemplateColumns: '64px 1fr 40px',
144
+ alignItems: 'center',
145
+ gap: 6,
146
+ fontSize: '0.75rem',
147
+ color: 'var(--color-text-muted)'
148
+ },
149
+ out: { textAlign: 'right', color: 'var(--color-text)', fontVariantNumeric: 'tabular-nums' },
150
+ toggles: {
151
+ display: 'flex',
152
+ flexWrap: 'wrap',
153
+ gap: 10,
154
+ alignItems: 'center',
155
+ fontSize: '0.75rem',
156
+ color: 'var(--color-text-muted)'
157
+ },
158
+ tabs: {
159
+ display: 'flex',
160
+ gap: 2,
161
+ borderBottom: '1px solid var(--color-border)',
162
+ overflowX: 'auto',
163
+ scrollbarWidth: 'none'
164
+ },
165
+ tab: {
166
+ flex: 'none',
167
+ padding: '7px 11px',
168
+ borderBottomWidth: 2,
169
+ borderBottomStyle: 'solid',
170
+ borderBottomColor: 'transparent',
171
+ borderTop: 0,
172
+ borderLeft: 0,
173
+ borderRight: 0,
174
+ background: 'none',
175
+ cursor: 'pointer',
176
+ fontSize: '0.82rem',
177
+ fontWeight: 500,
178
+ color: 'var(--color-text-muted)'
179
+ },
180
+ tabOn: { color: 'var(--color-text)', borderBottomColor: 'var(--color-primary)' },
181
+ count: { fontSize: '0.68rem', marginLeft: 4, opacity: 0.8, fontVariantNumeric: 'tabular-nums' },
182
+ pane: { display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 10 },
183
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))', gap: 8 },
184
+ tile: {
185
+ position: 'relative',
186
+ display: 'flex',
187
+ flexDirection: 'column',
188
+ gap: 5,
189
+ padding: 6,
190
+ borderRadius: 8,
191
+ border: '1px solid var(--color-border)',
192
+ background: 'var(--color-bg)',
193
+ color: 'var(--color-text)',
194
+ cursor: 'pointer',
195
+ textAlign: 'left',
196
+ font: 'inherit'
197
+ },
198
+ tileOn: {
199
+ borderColor: 'var(--color-primary)',
200
+ boxShadow: 'inset 0 0 0 1px var(--color-primary)'
201
+ },
202
+ tileTrying: { borderStyle: 'dashed', borderColor: 'var(--color-primary)' },
203
+ tilePv: {
204
+ height: 58,
205
+ borderRadius: 6,
206
+ display: 'flex',
207
+ alignItems: 'center',
208
+ justifyContent: 'center',
209
+ background: 'var(--color-bg-alt)',
210
+ overflow: 'hidden',
211
+ padding: '0 8px',
212
+ fontSize: '0.8rem'
213
+ },
214
+ tileName: { fontSize: '0.8rem', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 5 },
215
+ tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' },
216
+ foot: {
217
+ display: 'flex',
218
+ justifyContent: 'space-between',
219
+ alignItems: 'center',
220
+ gap: 8,
221
+ flexWrap: 'wrap',
222
+ fontSize: '0.75rem',
223
+ color: 'var(--color-text-muted)'
224
+ }
225
+ } satisfies Record<string, React.CSSProperties>
226
+
227
+ /**
228
+ * Trusted HTML from the cosmetics renderer, which escapes every string it is
229
+ * given. Memoised on the string and the style: React re-sets innerHTML on every
230
+ * render even when the string is the same, and replacing the nodes under the
231
+ * pointer between mousedown and mouseup swallows the click.
232
+ */
233
+ const Html = React.memo(
234
+ function Html({ html, style }: { html: string; style?: React.CSSProperties }) {
235
+ return <span style={style} dangerouslySetInnerHTML={{ __html: html }} />
236
+ },
237
+ (a, b) => a.html === b.html && JSON.stringify(a.style) === JSON.stringify(b.style)
238
+ )
239
+
240
+ /**
241
+ * Play a tile's message reveal again. A finished reveal holds no animation (it
242
+ * fills backwards only), so its markup is re-set. This runs synchronously in
243
+ * mouseenter, before any press, so it cannot split a click; the memo above keeps
244
+ * React from touching it afterwards.
245
+ */
246
+ function replayPreview(tile: HTMLElement) {
247
+ const pv = tile.firstElementChild
248
+ if (!pv) return
249
+ const markup = pv.innerHTML
250
+ pv.innerHTML = markup
251
+ }
252
+
253
+ function useNarrow(): boolean {
254
+ const query = '(max-width: 640px)'
255
+ const [narrow, setNarrow] = useState(() => window.matchMedia(query).matches)
256
+ useEffect(() => {
257
+ const mq = window.matchMedia(query)
258
+ const on = () => setNarrow(mq.matches)
259
+ mq.addEventListener('change', on)
260
+ return () => mq.removeEventListener('change', on)
261
+ }, [])
262
+ return narrow
263
+ }
264
+
265
+ /** Move every drawing of one overlay in `root` to `p`, without re-rendering (smooth while dragging). */
266
+ function applyPlacement(root: HTMLElement | null, id: string, p: Placement) {
267
+ root?.querySelectorAll<HTMLElement>(`.hc-art[data-hc-art="${id}"]`).forEach(ol => {
268
+ ol.style.zIndex = p.back ? '0' : '3'
269
+ const adj = ol.firstElementChild as HTMLElement | null
270
+ if (adj) adj.style.transform = placementTransform(p)
271
+ })
272
+ }
273
+
274
+ /** Settings with a new loadout, and what is worn recomputed from it. */
275
+ function withLoadout(s: AvatarSettings, loadout: Loadout): AvatarSettings {
276
+ return {
277
+ ...s,
278
+ cosmetics: { ...s.cosmetics, loadout },
279
+ avatar: { ...s.avatar, cosmetics: wornCosmetics(loadout) }
280
+ }
281
+ }
282
+
283
+ export interface AvatarBuilderProps {
284
+ /** The person's display name, for the preview. */
285
+ name: string | null
286
+ }
287
+
288
+ export function AvatarBuilder({ name }: AvatarBuilderProps) {
289
+ const [settings, setSettings] = useState<AvatarSettings | null>(null)
290
+ const [sets, setSets] = useState<CharacterSet[] | null>(null)
291
+ const [busy, setBusy] = useState(false)
292
+ const [error, setError] = useState<string | null>(null)
293
+ const [tab, setTab] = useState<Tab>('picture')
294
+ const [tryOn, setTryOn] = useState<{ slot: CosmeticSlot; id: string } | null>(null)
295
+ const [showLocked, setShowLocked] = useState(true)
296
+ const [replay, setReplay] = useState(0)
297
+ const narrow = useNarrow()
298
+ const root = useRef<HTMLDivElement>(null)
299
+ // Only the newest cosmetics request may set state: equips sent quickly in a
300
+ // row can answer out of order.
301
+ const seq = useRef(0)
302
+
303
+ useEffect(() => ensureCosmeticsStyles(), [])
304
+
305
+ useEffect(() => {
306
+ let live = true
307
+ avatarClient
308
+ .get()
309
+ .then(s => live && setSettings(s))
310
+ .catch((err: unknown) => live && setError(avatarErrorText(err)))
311
+ fetchCharacters()
312
+ .then(s => live && setSets(s))
313
+ .catch((err: unknown) => live && setError(avatarErrorText(err)))
314
+ return () => {
315
+ live = false
316
+ }
317
+ }, [])
318
+
319
+ const announce = (next: AvatarSettings) =>
320
+ // The page under the popout (a watchparty room, a game) re-reads the avatar
321
+ // from this instead of waiting for its next join.
322
+ window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
323
+
324
+ const run = useCallback(async (work: () => Promise<AvatarSettings>) => {
325
+ setBusy(true)
326
+ setError(null)
327
+ try {
328
+ const next = await work()
329
+ setSettings(next)
330
+ announce(next)
331
+ } catch (err) {
332
+ setError(avatarErrorText(err))
333
+ } finally {
334
+ setBusy(false)
335
+ }
336
+ }, [])
337
+
338
+ /** Save a cosmetics change, showing `optimistic` at once. A failure re-reads the truth. */
339
+ const wear = useCallback(
340
+ async (update: LoadoutUpdate, optimistic: (s: AvatarSettings) => AvatarSettings) => {
341
+ const mine = ++seq.current
342
+ setError(null)
343
+ setSettings(s => (s ? optimistic(s) : s))
344
+ try {
345
+ const next = await avatarClient.setCosmetics(update)
346
+ if (mine !== seq.current) return
347
+ setSettings(next)
348
+ announce(next)
349
+ } catch (err) {
350
+ if (mine !== seq.current) return
351
+ setError(avatarErrorText(err))
352
+ avatarClient
353
+ .get()
354
+ .then(s => mine === seq.current && setSettings(s))
355
+ .catch((e: unknown) => setError(avatarErrorText(e)))
356
+ }
357
+ },
358
+ []
359
+ )
360
+
361
+ const owned = useMemo(() => new Set(settings?.cosmetics.owned ?? []), [settings])
362
+ const loadout = settings?.cosmetics.loadout
363
+ const equippedOverlay = loadout?.equipped.overlay ?? null
364
+ const art: OverlayArt | undefined = equippedOverlay
365
+ ? COSMETICS_BY_ID.get(equippedOverlay)?.art
366
+ : undefined
367
+ const editing = tab === 'overlay' && !tryOn && !!art && !!equippedOverlay
368
+
369
+ // The placement being edited, live; saved a beat after the last change.
370
+ const [live, setLive] = useState<Placement>(PLACEMENT_DEFAULT)
371
+ const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
372
+ const dragging = useRef(false)
373
+ useEffect(() => {
374
+ if (dragging.current || !equippedOverlay) return
375
+ setLive(loadout?.placement[equippedOverlay] ?? PLACEMENT_DEFAULT)
376
+ }, [equippedOverlay, loadout])
377
+ useEffect(() => () => clearTimeout(saveTimer.current), [])
378
+
379
+ const place = useCallback(
380
+ (raw: Partial<Placement>, save: 'later' | 'now' | 'no') => {
381
+ if (!art || !equippedOverlay) return
382
+ const p = fitPlacement(art, raw)
383
+ setLive(p)
384
+ applyPlacement(root.current, equippedOverlay, p)
385
+ clearTimeout(saveTimer.current)
386
+ if (save === 'no') return
387
+ const commit = () =>
388
+ void wear({ placement: { [equippedOverlay]: p } }, s =>
389
+ withLoadout(s, {
390
+ ...s.cosmetics.loadout,
391
+ placement: { ...s.cosmetics.loadout.placement, [equippedOverlay]: p }
392
+ })
393
+ )
394
+ if (save === 'now') commit()
395
+ else saveTimer.current = setTimeout(commit, 400)
396
+ },
397
+ [art, equippedOverlay, wear]
398
+ )
399
+
400
+ if (!settings) {
401
+ return (
402
+ <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
403
+ {error ?? '…'}
404
+ </span>
405
+ )
406
+ }
407
+
408
+ const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
409
+ const character = settings.sprite ? characters.get(settings.sprite) : undefined
410
+ const picture = settings.avatar.url ?? (character ? portraitUrl(character) : null)
411
+ // Every name cosmetic derives its colours from the character's.
412
+ const colour: React.CSSProperties =
413
+ character && /^#[0-9a-f]{6}$/i.test(character.color)
414
+ ? ({ '--hc-base': character.color } as React.CSSProperties)
415
+ : {}
416
+ const displayName = name || 'You'
417
+
418
+ const worn: AvatarCosmetics = { ...(settings.avatar.cosmetics ?? {}) }
419
+ if (tryOn) {
420
+ worn[tryOn.slot] = tryOn.id
421
+ if (tryOn.slot === 'overlay') delete worn.placement
422
+ }
423
+
424
+ const equip = (slot: CosmeticSlot, id: string | null) => {
425
+ setTryOn(null)
426
+ void wear({ equip: { [slot]: id } }, s => {
427
+ const l = s.cosmetics.loadout
428
+ return withLoadout(s, cleanLoadout({ ...l, equipped: { ...l.equipped, [slot]: id } }, owned))
429
+ })
430
+ }
431
+
432
+ const stage = (
433
+ <Stage
434
+ html={
435
+ avatarHtml({ src: picture, size: 112, worn }) + nameHtml(displayName, worn, { large: true })
436
+ }
437
+ editing={editing}
438
+ overlayId={equippedOverlay}
439
+ live={live}
440
+ onPlace={place}
441
+ dragging={dragging}
442
+ style={colour}
443
+ banner={
444
+ tryOn && (
445
+ <div style={S.tryOn}>
446
+ <Icon name="lock" size={12} />
447
+ <span style={{ flex: 1, minWidth: 0 }}>
448
+ Trying on {COSMETICS_BY_ID.get(tryOn.id)?.label} ·{' '}
449
+ {COSMETICS_BY_ID.get(tryOn.id)?.earn}
450
+ </span>
451
+ <button type="button" style={S.linkBtn} onClick={() => setTryOn(null)}>
452
+ Take off
453
+ </button>
454
+ </div>
455
+ )
456
+ }
457
+ />
458
+ )
459
+
460
+ const chat = (
461
+ <div style={S.chat}>
462
+ <div style={S.chatHead}>
463
+ <span>In chat</span>
464
+ <button
465
+ type="button"
466
+ className="settings-popout__btn"
467
+ onClick={() => setReplay(n => n + 1)}
468
+ >
469
+ Replay
470
+ </button>
471
+ </div>
472
+ <div style={S.msg}>
473
+ <Html html={avatarHtml({ src: picture, size: 36, worn })} style={colour} />
474
+ <div style={{ minWidth: 0, ...colour }}>
475
+ <div style={S.who}>
476
+ <Html html={nameHtml(displayName, worn)} />
477
+ </div>
478
+ <div style={S.line}>
479
+ <Html key={replay} html={messageHtml('gg, that last hand was brutal', worn)} />
480
+ </div>
481
+ </div>
482
+ </div>
483
+ </div>
484
+ )
485
+
486
+ const placement = editing && art && (
487
+ <div style={S.place} role="group" aria-label="Placement">
488
+ <div style={S.placeHead}>
489
+ <strong>Placement</strong>
490
+ <span style={S.hint}>
491
+ Drag it on the avatar. The top handle turns it, the corner resizes it.
492
+ </span>
493
+ </div>
494
+ {SLIDERS.map(([k, label, unit]) => (
495
+ <label key={k} style={S.slider}>
496
+ <span>{label}</span>
497
+ <input
498
+ type="range"
499
+ min={PLACEMENT_LIMITS[k][0]}
500
+ max={PLACEMENT_LIMITS[k][1]}
501
+ value={live[k]}
502
+ onChange={e => place({ ...live, [k]: Number(e.currentTarget.value) }, 'later')}
503
+ style={{ width: '100%', accentColor: 'var(--color-primary)' }}
504
+ />
505
+ <output style={S.out}>
506
+ {live[k]}
507
+ {unit}
508
+ </output>
509
+ </label>
510
+ ))}
511
+ <div style={S.toggles}>
512
+ <label>
513
+ <input
514
+ type="checkbox"
515
+ checked={live.flip}
516
+ onChange={e => place({ ...live, flip: e.currentTarget.checked }, 'now')}
517
+ />{' '}
518
+ Mirror
519
+ </label>
520
+ <label>
521
+ <input
522
+ type="checkbox"
523
+ checked={live.back}
524
+ onChange={e => place({ ...live, back: e.currentTarget.checked }, 'now')}
525
+ />{' '}
526
+ Behind
527
+ </label>
528
+ <button
529
+ type="button"
530
+ className="settings-popout__btn"
531
+ onClick={() => place(PLACEMENT_DEFAULT, 'now')}
532
+ >
533
+ Reset
534
+ </button>
535
+ </div>
536
+ </div>
537
+ )
538
+
539
+ const tabs: Tab[] = ['picture', ...COSMETIC_SLOTS]
540
+ const tabLabel = (t: Tab) => (t === 'picture' ? 'Picture' : SLOT_LABEL[t])
541
+
542
+ return (
543
+ <div
544
+ ref={root}
545
+ style={{ ...S.body, gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : '240px minmax(0, 1fr)' }}
546
+ >
547
+ <div style={S.side}>
548
+ {stage}
549
+ {chat}
550
+ {placement}
551
+ </div>
552
+ <div style={{ minWidth: 0 }}>
553
+ <div style={S.tabs} role="tablist" aria-label="Avatar">
554
+ {tabs.map(t => {
555
+ const items = t === 'picture' ? [] : COSMETICS.filter(i => i.slot === t)
556
+ return (
557
+ <button
558
+ key={t}
559
+ type="button"
560
+ role="tab"
561
+ aria-selected={tab === t}
562
+ style={{ ...S.tab, ...(tab === t ? S.tabOn : {}) }}
563
+ onClick={() => {
564
+ setTab(t)
565
+ setTryOn(null)
566
+ }}
567
+ >
568
+ {tabLabel(t)}
569
+ {t !== 'picture' && (
570
+ <span style={S.count}>
571
+ {items.filter(i => owned.has(i.id)).length}/{items.length}
572
+ </span>
573
+ )}
574
+ </button>
575
+ )
576
+ })}
577
+ </div>
578
+ <div style={S.pane} role="tabpanel" aria-label={tabLabel(tab)}>
579
+ {tab === 'picture' ? (
580
+ <AvatarPicker settings={settings} sets={sets} busy={busy} run={run} />
581
+ ) : (
582
+ <SlotGrid
583
+ slot={tab}
584
+ owned={owned}
585
+ equipped={loadout?.equipped[tab] ?? null}
586
+ tryOn={tryOn}
587
+ showLocked={showLocked}
588
+ onShowLocked={setShowLocked}
589
+ preview={id => {
590
+ const only: AvatarCosmetics = { [tab]: id ?? undefined }
591
+ if (tab === 'overlay') return avatarHtml({ src: picture, size: 40, worn: only })
592
+ if (tab === 'message') return messageHtml('gg, rematch?', only, { keep: true })
593
+ // A name tile shows the item on top of what is worn in the other name slots.
594
+ return nameHtml(displayName, { ...worn, [tab]: id ?? undefined })
595
+ }}
596
+ colour={colour}
597
+ onEquip={id => equip(tab, id)}
598
+ onTry={id =>
599
+ setTryOn(t => (t?.slot === tab && t.id === id ? null : { slot: tab, id }))
600
+ }
601
+ />
602
+ )}
603
+ {error && (
604
+ <span className="settings-popout__error" role="alert">
605
+ {error}
606
+ </span>
607
+ )}
608
+ </div>
609
+ </div>
610
+ </div>
611
+ )
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+
616
+ interface StageProps {
617
+ html: string
618
+ editing: boolean
619
+ overlayId: string | null
620
+ live: Placement
621
+ onPlace: (p: Partial<Placement>, save: 'later' | 'now' | 'no') => void
622
+ dragging: React.MutableRefObject<boolean>
623
+ style: React.CSSProperties
624
+ banner: React.ReactNode
625
+ }
626
+
627
+ /** The big preview, and the placement handles drawn on its overlay while editing. */
628
+ function Stage({ html, editing, overlayId, live, onPlace, dragging, style, banner }: StageProps) {
629
+ const box = useRef<HTMLDivElement>(null)
630
+ const drag = useRef<{
631
+ mode: string
632
+ p0: Placement
633
+ x0: number
634
+ y0: number
635
+ cx: number
636
+ cy: number
637
+ a0: number
638
+ d0: number
639
+ size: number
640
+ last: Placement
641
+ } | null>(null)
642
+
643
+ // The handles live inside the overlay's placement box, so they turn and
644
+ // scale with it. Re-added whenever the preview is redrawn.
645
+ useEffect(() => {
646
+ const adj = box.current?.querySelector('.hc-art .hc-adj')
647
+ if (!editing || !adj) return
648
+ const gz = document.createElement('span')
649
+ gz.dataset.g = 'move'
650
+ gz.setAttribute('aria-hidden', 'true')
651
+ gz.style.cssText =
652
+ 'position:absolute;inset:-3px;border:1.5px dashed var(--color-primary);border-radius:6px;pointer-events:auto;cursor:move'
653
+ const handle = (g: string, css: string) => {
654
+ const h = document.createElement('i')
655
+ h.dataset.g = g
656
+ h.style.cssText = `position:absolute;pointer-events:auto;${css}`
657
+ gz.appendChild(h)
658
+ }
659
+ handle(
660
+ 'rot',
661
+ 'left:50%;top:-20px;width:12px;height:12px;margin-left:-6px;border-radius:50%;background:var(--color-primary);cursor:grab'
662
+ )
663
+ handle(
664
+ 'scale',
665
+ 'right:-7px;bottom:-7px;width:12px;height:12px;border-radius:3px;background:var(--color-bg-card);border:2px solid var(--color-primary);cursor:nwse-resize'
666
+ )
667
+ adj.appendChild(gz)
668
+ return () => gz.remove()
669
+ }, [html, editing])
670
+
671
+ const down = (e: React.PointerEvent) => {
672
+ const g = (e.target as HTMLElement).closest<HTMLElement>('[data-g]')
673
+ const adj = g?.closest('.hc-adj')
674
+ const avatar = box.current?.querySelector('.hc-av')
675
+ if (!g || !adj || !avatar || !editing || !overlayId) return
676
+ e.preventDefault()
677
+ g.setPointerCapture(e.pointerId)
678
+ const r = adj.getBoundingClientRect()
679
+ const cx = r.left + r.width / 2
680
+ const cy = r.top + r.height / 2
681
+ const size = avatar.getBoundingClientRect().width
682
+ dragging.current = true
683
+ drag.current = {
684
+ mode: g.dataset.g ?? 'move',
685
+ p0: live,
686
+ x0: e.clientX,
687
+ y0: e.clientY,
688
+ cx,
689
+ cy,
690
+ a0: Math.atan2(e.clientY - cy, e.clientX - cx),
691
+ d0: Math.hypot(e.clientX - cx, e.clientY - cy) || 1,
692
+ size,
693
+ last: live
694
+ }
695
+ }
696
+ const move = (e: React.PointerEvent) => {
697
+ const d = drag.current
698
+ if (!d) return
699
+ let next: Partial<Placement> = d.p0
700
+ if (d.mode === 'move') {
701
+ next = {
702
+ ...d.p0,
703
+ x: d.p0.x + ((e.clientX - d.x0) / d.size) * 100,
704
+ y: d.p0.y + ((e.clientY - d.y0) / d.size) * 100
705
+ }
706
+ } else if (d.mode === 'rot') {
707
+ const a = Math.atan2(e.clientY - d.cy, e.clientX - d.cx)
708
+ next = { ...d.p0, r: d.p0.r + ((a - d.a0) * 180) / Math.PI }
709
+ } else {
710
+ next = { ...d.p0, s: (d.p0.s * Math.hypot(e.clientX - d.cx, e.clientY - d.cy)) / d.d0 }
711
+ }
712
+ d.last = next as Placement
713
+ onPlace(next, 'no')
714
+ }
715
+ const up = () => {
716
+ const d = drag.current
717
+ if (!d) return
718
+ drag.current = null
719
+ dragging.current = false
720
+ onPlace(d.last, 'now')
721
+ }
722
+
723
+ return (
724
+ <div
725
+ ref={box}
726
+ role="group"
727
+ aria-label="Preview"
728
+ style={{ ...S.stage, ...style }}
729
+ onPointerDown={down}
730
+ onPointerMove={move}
731
+ onPointerUp={up}
732
+ onPointerCancel={up}
733
+ >
734
+ {banner}
735
+ <Html html={html} style={{ display: 'contents' }} />
736
+ </div>
737
+ )
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+
742
+ interface SlotGridProps {
743
+ slot: CosmeticSlot
744
+ owned: ReadonlySet<string>
745
+ equipped: string | null
746
+ tryOn: { slot: CosmeticSlot; id: string } | null
747
+ showLocked: boolean
748
+ onShowLocked: (on: boolean) => void
749
+ preview: (id: string | null) => string
750
+ colour: React.CSSProperties
751
+ onEquip: (id: string | null) => void
752
+ onTry: (id: string) => void
753
+ }
754
+
755
+ /** One slot's items: None, then everything, earned or not. */
756
+ function SlotGrid({
757
+ slot,
758
+ owned,
759
+ equipped,
760
+ tryOn,
761
+ showLocked,
762
+ onShowLocked,
763
+ preview,
764
+ colour,
765
+ onEquip,
766
+ onTry
767
+ }: SlotGridProps) {
768
+ const items = COSMETICS.filter(i => i.slot === slot)
769
+ const locked = items.filter(i => !owned.has(i.id))
770
+ const shown = showLocked ? items : items.filter(i => owned.has(i.id))
771
+
772
+ return (
773
+ <>
774
+ <div style={S.grid}>
775
+ <button
776
+ type="button"
777
+ aria-pressed={equipped === null}
778
+ style={{ ...S.tile, ...(equipped === null ? S.tileOn : {}) }}
779
+ onClick={() => onEquip(null)}
780
+ >
781
+ <Html html={preview(null)} style={{ ...S.tilePv, ...colour }} />
782
+ <span style={S.tileName}>None</span>
783
+ </button>
784
+ {shown.map(item => {
785
+ const own = owned.has(item.id)
786
+ const on = equipped === item.id
787
+ const trying = tryOn?.slot === slot && tryOn.id === item.id
788
+ return (
789
+ <button
790
+ key={item.id}
791
+ type="button"
792
+ aria-pressed={own ? on : trying}
793
+ aria-label={own ? item.label : `${item.label}, locked. ${item.earn}. Try it on.`}
794
+ style={{
795
+ ...S.tile,
796
+ ...(on ? S.tileOn : {}),
797
+ ...(trying ? S.tileTrying : {})
798
+ }}
799
+ onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
800
+ // A message reveal plays once; hovering its tile plays it again.
801
+ onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
802
+ >
803
+ <Html
804
+ html={preview(item.id)}
805
+ style={{ ...S.tilePv, ...colour, opacity: own ? 1 : 0.55 }}
806
+ />
807
+ <span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
808
+ {!own && <Icon name="lock" size={12} />}
809
+ {item.label}
810
+ </span>
811
+ {!own && <span style={S.tileEarn}>{item.earn}</span>}
812
+ </button>
813
+ )
814
+ })}
815
+ </div>
816
+ <div style={S.foot}>
817
+ <span>
818
+ {locked.length
819
+ ? `${locked.length} still to earn. Pick one to try it on.`
820
+ : 'You have earned all of these.'}
821
+ </span>
822
+ {locked.length > 0 && (
823
+ <label>
824
+ <input
825
+ type="checkbox"
826
+ checked={showLocked}
827
+ onChange={e => onShowLocked(e.currentTarget.checked)}
828
+ />{' '}
829
+ Show locked
830
+ </label>
831
+ )}
832
+ </div>
833
+ </>
834
+ )
835
+ }