@hadoku/task-ui-components 4.23.5 → 4.23.7

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 (34) 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 +731 -0
  4. package/dist/components/AvatarBuilder.js.map +1 -0
  5. package/dist/components/AvatarDialog.d.ts +6 -4
  6. package/dist/components/AvatarDialog.d.ts.map +1 -1
  7. package/dist/components/AvatarDialog.js +3 -3
  8. package/dist/components/AvatarDialog.js.map +1 -1
  9. package/dist/components/AvatarPicker.d.ts +21 -18
  10. package/dist/components/AvatarPicker.d.ts.map +1 -1
  11. package/dist/components/AvatarPicker.js +17 -145
  12. package/dist/components/AvatarPicker.js.map +1 -1
  13. package/dist/components/ConnectedSettings.d.ts.map +1 -1
  14. package/dist/components/ConnectedSettings.js +20 -1
  15. package/dist/components/ConnectedSettings.js.map +1 -1
  16. package/dist/components/LinkedAccounts.d.ts +21 -0
  17. package/dist/components/LinkedAccounts.d.ts.map +1 -0
  18. package/dist/components/LinkedAccounts.js +119 -0
  19. package/dist/components/LinkedAccounts.js.map +1 -0
  20. package/dist/lib/avatarData.d.ts +27 -0
  21. package/dist/lib/avatarData.d.ts.map +1 -0
  22. package/dist/lib/avatarData.js +23 -0
  23. package/dist/lib/avatarData.js.map +1 -0
  24. package/dist/vendor/wolffm/prefs-client/avatar.js +7 -1
  25. package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
  26. package/dist/vendor/wolffm/prefs-client/cosmetics.js +491 -0
  27. package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/components/AvatarBuilder.tsx +901 -0
  30. package/src/components/AvatarDialog.tsx +8 -6
  31. package/src/components/AvatarPicker.tsx +45 -228
  32. package/src/components/ConnectedSettings.tsx +26 -0
  33. package/src/components/LinkedAccounts.tsx +176 -0
  34. package/src/lib/avatarData.ts +40 -0
@@ -0,0 +1,901 @@
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
+ cursor: 'grab',
162
+ touchAction: 'pan-x',
163
+ borderBottom: '1px solid var(--color-border)',
164
+ overflowX: 'auto',
165
+ scrollbarWidth: 'none'
166
+ },
167
+ tab: {
168
+ flex: 'none',
169
+ padding: '7px 11px',
170
+ borderBottomWidth: 2,
171
+ borderBottomStyle: 'solid',
172
+ borderBottomColor: 'transparent',
173
+ borderTop: 0,
174
+ borderLeft: 0,
175
+ borderRight: 0,
176
+ background: 'none',
177
+ cursor: 'pointer',
178
+ fontSize: '0.82rem',
179
+ fontWeight: 500,
180
+ color: 'var(--color-text-muted)'
181
+ },
182
+ tabOn: { color: 'var(--color-text)', borderBottomColor: 'var(--color-primary)' },
183
+ count: { fontSize: '0.68rem', marginLeft: 4, opacity: 0.8, fontVariantNumeric: 'tabular-nums' },
184
+ pane: { display: 'flex', flexDirection: 'column', gap: 10, paddingTop: 10 },
185
+ grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))', gap: 8 },
186
+ tile: {
187
+ position: 'relative',
188
+ display: 'flex',
189
+ flexDirection: 'column',
190
+ gap: 5,
191
+ padding: 6,
192
+ borderRadius: 8,
193
+ border: '1px solid var(--color-border)',
194
+ background: 'var(--color-bg)',
195
+ color: 'var(--color-text)',
196
+ cursor: 'pointer',
197
+ textAlign: 'left',
198
+ font: 'inherit'
199
+ },
200
+ tileOn: {
201
+ borderColor: 'var(--color-primary)',
202
+ boxShadow: 'inset 0 0 0 1px var(--color-primary)'
203
+ },
204
+ tileTrying: { borderStyle: 'dashed', borderColor: 'var(--color-primary)' },
205
+ tilePv: {
206
+ height: 58,
207
+ borderRadius: 6,
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ justifyContent: 'center',
211
+ background: 'var(--color-bg-alt)',
212
+ overflow: 'hidden',
213
+ padding: '0 8px',
214
+ fontSize: '0.8rem'
215
+ },
216
+ tileName: { fontSize: '0.8rem', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 5 },
217
+ tileEarn: { fontSize: '0.7rem', lineHeight: 1.3, color: 'var(--color-text-muted)' },
218
+ foot: {
219
+ display: 'flex',
220
+ justifyContent: 'space-between',
221
+ alignItems: 'center',
222
+ gap: 8,
223
+ flexWrap: 'wrap',
224
+ fontSize: '0.75rem',
225
+ color: 'var(--color-text-muted)'
226
+ }
227
+ } satisfies Record<string, React.CSSProperties>
228
+
229
+ /**
230
+ * Trusted HTML from the cosmetics renderer, which escapes every string it is
231
+ * given. Memoised on the string and the style: React re-sets innerHTML on every
232
+ * render even when the string is the same, and replacing the nodes under the
233
+ * pointer between mousedown and mouseup swallows the click.
234
+ */
235
+ const Html = React.memo(
236
+ function Html({ html, style }: { html: string; style?: React.CSSProperties }) {
237
+ return <span style={style} dangerouslySetInnerHTML={{ __html: html }} />
238
+ },
239
+ (a, b) => a.html === b.html && JSON.stringify(a.style) === JSON.stringify(b.style)
240
+ )
241
+
242
+ /**
243
+ * Play a tile's message reveal again. A finished reveal holds no animation (it
244
+ * fills backwards only), so its markup is re-set. This runs synchronously in
245
+ * mouseenter, before any press, so it cannot split a click; the memo above keeps
246
+ * React from touching it afterwards.
247
+ */
248
+ function replayPreview(tile: HTMLElement) {
249
+ const pv = tile.firstElementChild
250
+ if (!pv) return
251
+ const markup = pv.innerHTML
252
+ pv.innerHTML = markup
253
+ }
254
+
255
+ function useNarrow(): boolean {
256
+ const query = '(max-width: 640px)'
257
+ const [narrow, setNarrow] = useState(() => window.matchMedia(query).matches)
258
+ useEffect(() => {
259
+ const mq = window.matchMedia(query)
260
+ const on = () => setNarrow(mq.matches)
261
+ mq.addEventListener('change', on)
262
+ return () => mq.removeEventListener('change', on)
263
+ }, [])
264
+ return narrow
265
+ }
266
+
267
+ /** Move every drawing of one overlay in `root` to `p`, without re-rendering (smooth while dragging). */
268
+ function applyPlacement(root: HTMLElement | null, id: string, p: Placement) {
269
+ root?.querySelectorAll<HTMLElement>(`.hc-art[data-hc-art="${id}"]`).forEach(ol => {
270
+ ol.style.zIndex = p.back ? '0' : '3'
271
+ const adj = ol.firstElementChild as HTMLElement | null
272
+ if (adj) adj.style.transform = placementTransform(p)
273
+ })
274
+ }
275
+
276
+ /** Settings with a new loadout, and what is worn recomputed from it. */
277
+ function withLoadout(s: AvatarSettings, loadout: Loadout): AvatarSettings {
278
+ return {
279
+ ...s,
280
+ cosmetics: { ...s.cosmetics, loadout },
281
+ avatar: { ...s.avatar, cosmetics: wornCosmetics(loadout) }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * A row that overflows sideways scrolls by dragging it (the operator's ask: the
287
+ * tab strip is wider than its column and hides its scrollbar) and by the
288
+ * mouse wheel. A drag of more than a few pixels is not a click.
289
+ */
290
+ function useDragScroll<T extends HTMLElement>() {
291
+ // A callback ref, not useRef: the row appears only once the settings load,
292
+ // after the first render, and the listeners must attach when it does.
293
+ const [el, setEl] = useState<T | null>(null)
294
+ useEffect(() => {
295
+ if (!el) return
296
+ let start: { x: number; left: number } | null = null
297
+ let dragged = false
298
+ const down = (e: PointerEvent) => {
299
+ if (e.button !== 0) return
300
+ start = { x: e.clientX, left: el.scrollLeft }
301
+ dragged = false
302
+ }
303
+ const move = (e: PointerEvent) => {
304
+ if (!start) return
305
+ const dx = e.clientX - start.x
306
+ if (!dragged && Math.abs(dx) > 4) {
307
+ dragged = true
308
+ el.setPointerCapture(e.pointerId)
309
+ el.style.cursor = 'grabbing'
310
+ }
311
+ if (dragged) el.scrollLeft = start.left - dx
312
+ }
313
+ const up = () => {
314
+ start = null
315
+ el.style.cursor = ''
316
+ }
317
+ // The click that ends a drag is not a tab choice.
318
+ const click = (e: MouseEvent) => {
319
+ if (!dragged) return
320
+ e.preventDefault()
321
+ e.stopPropagation()
322
+ dragged = false
323
+ }
324
+ const wheel = (e: globalThis.WheelEvent) => {
325
+ if (el.scrollWidth <= el.clientWidth || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return
326
+ e.preventDefault()
327
+ el.scrollLeft += e.deltaY
328
+ }
329
+ el.addEventListener('pointerdown', down)
330
+ el.addEventListener('pointermove', move)
331
+ el.addEventListener('pointerup', up)
332
+ el.addEventListener('pointercancel', up)
333
+ el.addEventListener('click', click, true)
334
+ el.addEventListener('wheel', wheel, { passive: false })
335
+ return () => {
336
+ el.removeEventListener('pointerdown', down)
337
+ el.removeEventListener('pointermove', move)
338
+ el.removeEventListener('pointerup', up)
339
+ el.removeEventListener('pointercancel', up)
340
+ el.removeEventListener('click', click, true)
341
+ el.removeEventListener('wheel', wheel)
342
+ }
343
+ }, [el])
344
+ return setEl
345
+ }
346
+
347
+ export interface AvatarBuilderProps {
348
+ /** The person's display name, for the preview. */
349
+ name: string | null
350
+ }
351
+
352
+ export function AvatarBuilder({ name }: AvatarBuilderProps) {
353
+ const [settings, setSettings] = useState<AvatarSettings | null>(null)
354
+ const [sets, setSets] = useState<CharacterSet[] | null>(null)
355
+ const [busy, setBusy] = useState(false)
356
+ const [error, setError] = useState<string | null>(null)
357
+ const [tab, setTab] = useState<Tab>('picture')
358
+ const [tryOn, setTryOn] = useState<{ slot: CosmeticSlot; id: string } | null>(null)
359
+ const [showLocked, setShowLocked] = useState(true)
360
+ const [replay, setReplay] = useState(0)
361
+ const narrow = useNarrow()
362
+ const root = useRef<HTMLDivElement>(null)
363
+ const tabStrip = useDragScroll<HTMLDivElement>()
364
+ // Only the newest cosmetics request may set state: equips sent quickly in a
365
+ // row can answer out of order.
366
+ const seq = useRef(0)
367
+
368
+ useEffect(() => ensureCosmeticsStyles(), [])
369
+
370
+ useEffect(() => {
371
+ let live = true
372
+ avatarClient
373
+ .get()
374
+ .then(s => live && setSettings(s))
375
+ .catch((err: unknown) => live && setError(avatarErrorText(err)))
376
+ fetchCharacters()
377
+ .then(s => live && setSets(s))
378
+ .catch((err: unknown) => live && setError(avatarErrorText(err)))
379
+ return () => {
380
+ live = false
381
+ }
382
+ }, [])
383
+
384
+ const announce = (next: AvatarSettings) =>
385
+ // The page under the popout (a watchparty room, a game) re-reads the avatar
386
+ // from this instead of waiting for its next join.
387
+ window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
388
+
389
+ const run = useCallback(async (work: () => Promise<AvatarSettings>) => {
390
+ setBusy(true)
391
+ setError(null)
392
+ try {
393
+ const next = await work()
394
+ setSettings(next)
395
+ announce(next)
396
+ } catch (err) {
397
+ setError(avatarErrorText(err))
398
+ } finally {
399
+ setBusy(false)
400
+ }
401
+ }, [])
402
+
403
+ /** Save a cosmetics change, showing `optimistic` at once. A failure re-reads the truth. */
404
+ const wear = useCallback(
405
+ async (update: LoadoutUpdate, optimistic: (s: AvatarSettings) => AvatarSettings) => {
406
+ const mine = ++seq.current
407
+ setError(null)
408
+ setSettings(s => (s ? optimistic(s) : s))
409
+ try {
410
+ const next = await avatarClient.setCosmetics(update)
411
+ if (mine !== seq.current) return
412
+ setSettings(next)
413
+ announce(next)
414
+ } catch (err) {
415
+ if (mine !== seq.current) return
416
+ setError(avatarErrorText(err))
417
+ avatarClient
418
+ .get()
419
+ .then(s => mine === seq.current && setSettings(s))
420
+ .catch((e: unknown) => setError(avatarErrorText(e)))
421
+ }
422
+ },
423
+ []
424
+ )
425
+
426
+ const owned = useMemo(() => new Set(settings?.cosmetics.owned ?? []), [settings])
427
+ const loadout = settings?.cosmetics.loadout
428
+ const equippedOverlay = loadout?.equipped.overlay ?? null
429
+ const art: OverlayArt | undefined = equippedOverlay
430
+ ? COSMETICS_BY_ID.get(equippedOverlay)?.art
431
+ : undefined
432
+ const editing = tab === 'overlay' && !tryOn && !!art && !!equippedOverlay
433
+
434
+ // The placement being edited, live; saved a beat after the last change.
435
+ const [live, setLive] = useState<Placement>(PLACEMENT_DEFAULT)
436
+ const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
437
+ const dragging = useRef(false)
438
+ useEffect(() => {
439
+ if (dragging.current || !equippedOverlay) return
440
+ setLive(loadout?.placement[equippedOverlay] ?? PLACEMENT_DEFAULT)
441
+ }, [equippedOverlay, loadout])
442
+ useEffect(() => () => clearTimeout(saveTimer.current), [])
443
+
444
+ const place = useCallback(
445
+ (raw: Partial<Placement>, save: 'later' | 'now' | 'no') => {
446
+ if (!art || !equippedOverlay) return
447
+ const p = fitPlacement(art, raw)
448
+ setLive(p)
449
+ applyPlacement(root.current, equippedOverlay, p)
450
+ clearTimeout(saveTimer.current)
451
+ if (save === 'no') return
452
+ const commit = () =>
453
+ void wear({ placement: { [equippedOverlay]: p } }, s =>
454
+ withLoadout(s, {
455
+ ...s.cosmetics.loadout,
456
+ placement: { ...s.cosmetics.loadout.placement, [equippedOverlay]: p }
457
+ })
458
+ )
459
+ if (save === 'now') commit()
460
+ else saveTimer.current = setTimeout(commit, 400)
461
+ },
462
+ [art, equippedOverlay, wear]
463
+ )
464
+
465
+ if (!settings) {
466
+ return (
467
+ <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
468
+ {error ?? '…'}
469
+ </span>
470
+ )
471
+ }
472
+
473
+ const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
474
+ const character = settings.sprite ? characters.get(settings.sprite) : undefined
475
+ const picture = settings.avatar.url ?? (character ? portraitUrl(character) : null)
476
+ // Every name cosmetic derives its colours from the character's.
477
+ const colour: React.CSSProperties =
478
+ character && /^#[0-9a-f]{6}$/i.test(character.color)
479
+ ? ({ '--hc-base': character.color } as React.CSSProperties)
480
+ : {}
481
+ const displayName = name || 'You'
482
+
483
+ const worn: AvatarCosmetics = { ...(settings.avatar.cosmetics ?? {}) }
484
+ if (tryOn) {
485
+ worn[tryOn.slot] = tryOn.id
486
+ if (tryOn.slot === 'overlay') delete worn.placement
487
+ }
488
+
489
+ const equip = (slot: CosmeticSlot, id: string | null) => {
490
+ setTryOn(null)
491
+ void wear({ equip: { [slot]: id } }, s => {
492
+ const l = s.cosmetics.loadout
493
+ return withLoadout(s, cleanLoadout({ ...l, equipped: { ...l.equipped, [slot]: id } }, owned))
494
+ })
495
+ }
496
+
497
+ const stage = (
498
+ <Stage
499
+ html={
500
+ avatarHtml({ src: picture, size: 112, worn }) + nameHtml(displayName, worn, { large: true })
501
+ }
502
+ editing={editing}
503
+ overlayId={equippedOverlay}
504
+ live={live}
505
+ onPlace={place}
506
+ dragging={dragging}
507
+ style={colour}
508
+ banner={
509
+ tryOn && (
510
+ <div style={S.tryOn}>
511
+ <Icon name="lock" size={12} />
512
+ <span style={{ flex: 1, minWidth: 0 }}>
513
+ Trying on {COSMETICS_BY_ID.get(tryOn.id)?.label} ·{' '}
514
+ {COSMETICS_BY_ID.get(tryOn.id)?.earn}
515
+ </span>
516
+ <button type="button" style={S.linkBtn} onClick={() => setTryOn(null)}>
517
+ Take off
518
+ </button>
519
+ </div>
520
+ )
521
+ }
522
+ />
523
+ )
524
+
525
+ const chat = (
526
+ <div style={S.chat}>
527
+ <div style={S.chatHead}>
528
+ <span>In chat</span>
529
+ <button
530
+ type="button"
531
+ className="settings-popout__btn"
532
+ onClick={() => setReplay(n => n + 1)}
533
+ >
534
+ Replay
535
+ </button>
536
+ </div>
537
+ <div style={S.msg}>
538
+ <Html html={avatarHtml({ src: picture, size: 36, worn })} style={colour} />
539
+ <div style={{ minWidth: 0, ...colour }}>
540
+ <div style={S.who}>
541
+ <Html html={nameHtml(displayName, worn)} />
542
+ </div>
543
+ <div style={S.line}>
544
+ <Html key={replay} html={messageHtml('gg, that last hand was brutal', worn)} />
545
+ </div>
546
+ </div>
547
+ </div>
548
+ </div>
549
+ )
550
+
551
+ const placement = editing && art && (
552
+ <div style={S.place} role="group" aria-label="Placement">
553
+ <div style={S.placeHead}>
554
+ <strong>Placement</strong>
555
+ <span style={S.hint}>
556
+ Drag it on the avatar. The top handle turns it, the corner resizes it.
557
+ </span>
558
+ </div>
559
+ {SLIDERS.map(([k, label, unit]) => (
560
+ <label key={k} style={S.slider}>
561
+ <span>{label}</span>
562
+ <input
563
+ type="range"
564
+ min={PLACEMENT_LIMITS[k][0]}
565
+ max={PLACEMENT_LIMITS[k][1]}
566
+ value={live[k]}
567
+ onChange={e => place({ ...live, [k]: Number(e.currentTarget.value) }, 'later')}
568
+ style={{ width: '100%', accentColor: 'var(--color-primary)' }}
569
+ />
570
+ <output style={S.out}>
571
+ {live[k]}
572
+ {unit}
573
+ </output>
574
+ </label>
575
+ ))}
576
+ <div style={S.toggles}>
577
+ <label>
578
+ <input
579
+ type="checkbox"
580
+ checked={live.flip}
581
+ onChange={e => place({ ...live, flip: e.currentTarget.checked }, 'now')}
582
+ />{' '}
583
+ Mirror
584
+ </label>
585
+ <label>
586
+ <input
587
+ type="checkbox"
588
+ checked={live.back}
589
+ onChange={e => place({ ...live, back: e.currentTarget.checked }, 'now')}
590
+ />{' '}
591
+ Behind
592
+ </label>
593
+ <button
594
+ type="button"
595
+ className="settings-popout__btn"
596
+ onClick={() => place(PLACEMENT_DEFAULT, 'now')}
597
+ >
598
+ Reset
599
+ </button>
600
+ </div>
601
+ </div>
602
+ )
603
+
604
+ const tabs: Tab[] = ['picture', ...COSMETIC_SLOTS]
605
+ const tabLabel = (t: Tab) => (t === 'picture' ? 'Picture' : SLOT_LABEL[t])
606
+
607
+ return (
608
+ <div
609
+ ref={root}
610
+ style={{ ...S.body, gridTemplateColumns: narrow ? 'minmax(0, 1fr)' : '240px minmax(0, 1fr)' }}
611
+ >
612
+ <div style={S.side}>
613
+ {stage}
614
+ {chat}
615
+ {placement}
616
+ </div>
617
+ <div style={{ minWidth: 0 }}>
618
+ <div ref={tabStrip} style={S.tabs} role="tablist" aria-label="Avatar">
619
+ {tabs.map(t => {
620
+ const items = t === 'picture' ? [] : COSMETICS.filter(i => i.slot === t)
621
+ return (
622
+ <button
623
+ key={t}
624
+ type="button"
625
+ role="tab"
626
+ aria-selected={tab === t}
627
+ style={{ ...S.tab, ...(tab === t ? S.tabOn : {}) }}
628
+ onClick={e => {
629
+ setTab(t)
630
+ setTryOn(null)
631
+ e.currentTarget.scrollIntoView({ block: 'nearest', inline: 'nearest' })
632
+ }}
633
+ >
634
+ {tabLabel(t)}
635
+ {t !== 'picture' && (
636
+ <span style={S.count}>
637
+ {items.filter(i => owned.has(i.id)).length}/{items.length}
638
+ </span>
639
+ )}
640
+ </button>
641
+ )
642
+ })}
643
+ </div>
644
+ <div style={S.pane} role="tabpanel" aria-label={tabLabel(tab)}>
645
+ {tab === 'picture' ? (
646
+ <AvatarPicker settings={settings} sets={sets} busy={busy} run={run} />
647
+ ) : (
648
+ <SlotGrid
649
+ slot={tab}
650
+ owned={owned}
651
+ equipped={loadout?.equipped[tab] ?? null}
652
+ tryOn={tryOn}
653
+ showLocked={showLocked}
654
+ onShowLocked={setShowLocked}
655
+ preview={id => {
656
+ const only: AvatarCosmetics = { [tab]: id ?? undefined }
657
+ if (tab === 'overlay') return avatarHtml({ src: picture, size: 40, worn: only })
658
+ if (tab === 'message') return messageHtml('gg, rematch?', only, { keep: true })
659
+ // A name tile shows the item on top of what is worn in the other name slots.
660
+ return nameHtml(displayName, { ...worn, [tab]: id ?? undefined })
661
+ }}
662
+ colour={colour}
663
+ onEquip={id => equip(tab, id)}
664
+ onTry={id =>
665
+ setTryOn(t => (t?.slot === tab && t.id === id ? null : { slot: tab, id }))
666
+ }
667
+ />
668
+ )}
669
+ {error && (
670
+ <span className="settings-popout__error" role="alert">
671
+ {error}
672
+ </span>
673
+ )}
674
+ </div>
675
+ </div>
676
+ </div>
677
+ )
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+
682
+ interface StageProps {
683
+ html: string
684
+ editing: boolean
685
+ overlayId: string | null
686
+ live: Placement
687
+ onPlace: (p: Partial<Placement>, save: 'later' | 'now' | 'no') => void
688
+ dragging: React.MutableRefObject<boolean>
689
+ style: React.CSSProperties
690
+ banner: React.ReactNode
691
+ }
692
+
693
+ /** The big preview, and the placement handles drawn on its overlay while editing. */
694
+ function Stage({ html, editing, overlayId, live, onPlace, dragging, style, banner }: StageProps) {
695
+ const box = useRef<HTMLDivElement>(null)
696
+ const drag = useRef<{
697
+ mode: string
698
+ p0: Placement
699
+ x0: number
700
+ y0: number
701
+ cx: number
702
+ cy: number
703
+ a0: number
704
+ d0: number
705
+ size: number
706
+ last: Placement
707
+ } | null>(null)
708
+
709
+ // The handles live inside the overlay's placement box, so they turn and
710
+ // scale with it. Re-added whenever the preview is redrawn.
711
+ useEffect(() => {
712
+ const adj = box.current?.querySelector('.hc-art .hc-adj')
713
+ if (!editing || !adj) return
714
+ const gz = document.createElement('span')
715
+ gz.dataset.g = 'move'
716
+ gz.setAttribute('aria-hidden', 'true')
717
+ gz.style.cssText =
718
+ 'position:absolute;inset:-3px;border:1.5px dashed var(--color-primary);border-radius:6px;pointer-events:auto;cursor:move'
719
+ const handle = (g: string, css: string) => {
720
+ const h = document.createElement('i')
721
+ h.dataset.g = g
722
+ h.style.cssText = `position:absolute;pointer-events:auto;${css}`
723
+ gz.appendChild(h)
724
+ }
725
+ handle(
726
+ 'rot',
727
+ 'left:50%;top:-20px;width:12px;height:12px;margin-left:-6px;border-radius:50%;background:var(--color-primary);cursor:grab'
728
+ )
729
+ handle(
730
+ 'scale',
731
+ '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'
732
+ )
733
+ adj.appendChild(gz)
734
+ return () => gz.remove()
735
+ }, [html, editing])
736
+
737
+ const down = (e: React.PointerEvent) => {
738
+ const g = (e.target as HTMLElement).closest<HTMLElement>('[data-g]')
739
+ const adj = g?.closest('.hc-adj')
740
+ const avatar = box.current?.querySelector('.hc-av')
741
+ if (!g || !adj || !avatar || !editing || !overlayId) return
742
+ e.preventDefault()
743
+ g.setPointerCapture(e.pointerId)
744
+ const r = adj.getBoundingClientRect()
745
+ const cx = r.left + r.width / 2
746
+ const cy = r.top + r.height / 2
747
+ const size = avatar.getBoundingClientRect().width
748
+ dragging.current = true
749
+ drag.current = {
750
+ mode: g.dataset.g ?? 'move',
751
+ p0: live,
752
+ x0: e.clientX,
753
+ y0: e.clientY,
754
+ cx,
755
+ cy,
756
+ a0: Math.atan2(e.clientY - cy, e.clientX - cx),
757
+ d0: Math.hypot(e.clientX - cx, e.clientY - cy) || 1,
758
+ size,
759
+ last: live
760
+ }
761
+ }
762
+ const move = (e: React.PointerEvent) => {
763
+ const d = drag.current
764
+ if (!d) return
765
+ let next: Partial<Placement> = d.p0
766
+ if (d.mode === 'move') {
767
+ next = {
768
+ ...d.p0,
769
+ x: d.p0.x + ((e.clientX - d.x0) / d.size) * 100,
770
+ y: d.p0.y + ((e.clientY - d.y0) / d.size) * 100
771
+ }
772
+ } else if (d.mode === 'rot') {
773
+ const a = Math.atan2(e.clientY - d.cy, e.clientX - d.cx)
774
+ next = { ...d.p0, r: d.p0.r + ((a - d.a0) * 180) / Math.PI }
775
+ } else {
776
+ next = { ...d.p0, s: (d.p0.s * Math.hypot(e.clientX - d.cx, e.clientY - d.cy)) / d.d0 }
777
+ }
778
+ d.last = next as Placement
779
+ onPlace(next, 'no')
780
+ }
781
+ const up = () => {
782
+ const d = drag.current
783
+ if (!d) return
784
+ drag.current = null
785
+ dragging.current = false
786
+ onPlace(d.last, 'now')
787
+ }
788
+
789
+ return (
790
+ <div
791
+ ref={box}
792
+ role="group"
793
+ aria-label="Preview"
794
+ style={{ ...S.stage, ...style }}
795
+ onPointerDown={down}
796
+ onPointerMove={move}
797
+ onPointerUp={up}
798
+ onPointerCancel={up}
799
+ >
800
+ {banner}
801
+ <Html html={html} style={{ display: 'contents' }} />
802
+ </div>
803
+ )
804
+ }
805
+
806
+ // ---------------------------------------------------------------------------
807
+
808
+ interface SlotGridProps {
809
+ slot: CosmeticSlot
810
+ owned: ReadonlySet<string>
811
+ equipped: string | null
812
+ tryOn: { slot: CosmeticSlot; id: string } | null
813
+ showLocked: boolean
814
+ onShowLocked: (on: boolean) => void
815
+ preview: (id: string | null) => string
816
+ colour: React.CSSProperties
817
+ onEquip: (id: string | null) => void
818
+ onTry: (id: string) => void
819
+ }
820
+
821
+ /** One slot's items: None, then everything, earned or not. */
822
+ function SlotGrid({
823
+ slot,
824
+ owned,
825
+ equipped,
826
+ tryOn,
827
+ showLocked,
828
+ onShowLocked,
829
+ preview,
830
+ colour,
831
+ onEquip,
832
+ onTry
833
+ }: SlotGridProps) {
834
+ const items = COSMETICS.filter(i => i.slot === slot)
835
+ const locked = items.filter(i => !owned.has(i.id))
836
+ const shown = showLocked ? items : items.filter(i => owned.has(i.id))
837
+
838
+ return (
839
+ <>
840
+ <div style={S.grid}>
841
+ <button
842
+ type="button"
843
+ aria-pressed={equipped === null}
844
+ style={{ ...S.tile, ...(equipped === null ? S.tileOn : {}) }}
845
+ onClick={() => onEquip(null)}
846
+ >
847
+ <Html html={preview(null)} style={{ ...S.tilePv, ...colour }} />
848
+ <span style={S.tileName}>None</span>
849
+ </button>
850
+ {shown.map(item => {
851
+ const own = owned.has(item.id)
852
+ const on = equipped === item.id
853
+ const trying = tryOn?.slot === slot && tryOn.id === item.id
854
+ return (
855
+ <button
856
+ key={item.id}
857
+ type="button"
858
+ aria-pressed={own ? on : trying}
859
+ aria-label={own ? item.label : `${item.label}, locked. ${item.earn}. Try it on.`}
860
+ style={{
861
+ ...S.tile,
862
+ ...(on ? S.tileOn : {}),
863
+ ...(trying ? S.tileTrying : {})
864
+ }}
865
+ onClick={() => (own ? onEquip(item.id) : onTry(item.id))}
866
+ // A message reveal plays once; hovering its tile plays it again.
867
+ onMouseEnter={e => slot === 'message' && replayPreview(e.currentTarget)}
868
+ >
869
+ <Html
870
+ html={preview(item.id)}
871
+ style={{ ...S.tilePv, ...colour, opacity: own ? 1 : 0.55 }}
872
+ />
873
+ <span style={{ ...S.tileName, color: own ? undefined : 'var(--color-text-muted)' }}>
874
+ {!own && <Icon name="lock" size={12} />}
875
+ {item.label}
876
+ </span>
877
+ {!own && <span style={S.tileEarn}>{item.earn}</span>}
878
+ </button>
879
+ )
880
+ })}
881
+ </div>
882
+ <div style={S.foot}>
883
+ <span>
884
+ {locked.length
885
+ ? `${locked.length} still to earn. Pick one to try it on.`
886
+ : 'You have earned all of these.'}
887
+ </span>
888
+ {locked.length > 0 && (
889
+ <label>
890
+ <input
891
+ type="checkbox"
892
+ checked={showLocked}
893
+ onChange={e => onShowLocked(e.currentTarget.checked)}
894
+ />{' '}
895
+ Show locked
896
+ </label>
897
+ )}
898
+ </div>
899
+ </>
900
+ )
901
+ }