@skinhub/viewer 0.1.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.
Files changed (48) hide show
  1. package/EMBED.md +442 -0
  2. package/README.md +153 -0
  3. package/dist/SkinViewer.d.ts +18 -0
  4. package/dist/SkinViewer.d.ts.map +1 -0
  5. package/dist/SkinViewer.js +404 -0
  6. package/dist/SkinViewer.js.map +1 -0
  7. package/dist/index.d.ts +53 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +51 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/item.d.ts +118 -0
  12. package/dist/item.d.ts.map +1 -0
  13. package/dist/item.js +319 -0
  14. package/dist/item.js.map +1 -0
  15. package/dist/link.d.ts +30 -0
  16. package/dist/link.d.ts.map +1 -0
  17. package/dist/link.js +18 -0
  18. package/dist/link.js.map +1 -0
  19. package/dist/protocol.d.ts +231 -0
  20. package/dist/protocol.d.ts.map +1 -0
  21. package/dist/protocol.js +128 -0
  22. package/dist/protocol.js.map +1 -0
  23. package/dist/state.d.ts +107 -0
  24. package/dist/state.d.ts.map +1 -0
  25. package/dist/state.js +351 -0
  26. package/dist/state.js.map +1 -0
  27. package/dist/types.d.ts +573 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +106 -0
  30. package/dist/types.js.map +1 -0
  31. package/dist/useSkinViewer.d.ts +3 -0
  32. package/dist/useSkinViewer.d.ts.map +1 -0
  33. package/dist/useSkinViewer.js +66 -0
  34. package/dist/useSkinViewer.js.map +1 -0
  35. package/dist/weapons.d.ts +109 -0
  36. package/dist/weapons.d.ts.map +1 -0
  37. package/dist/weapons.js +260 -0
  38. package/dist/weapons.js.map +1 -0
  39. package/package.json +65 -0
  40. package/src/SkinViewer.tsx +465 -0
  41. package/src/index.ts +88 -0
  42. package/src/item.ts +373 -0
  43. package/src/link.ts +33 -0
  44. package/src/protocol.ts +241 -0
  45. package/src/state.ts +389 -0
  46. package/src/types.ts +672 -0
  47. package/src/useSkinViewer.ts +80 -0
  48. package/src/weapons.ts +284 -0
package/src/types.ts ADDED
@@ -0,0 +1,672 @@
1
+ /**
2
+ * *** `@skinhub/viewer` - THE PUBLIC PROP CONTRACT. ***
3
+ *
4
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
5
+ * *** WHAT THIS PACKAGE IS, AND THE ONE SENTENCE THAT DECIDES EVERY QUESTION BELOW. ***
6
+ *
7
+ * The renderer is a page on our origin (`/frame`). This package renders an `<iframe>` at it and
8
+ * speaks its message protocol. Owner, 2026-08-15: *"the package is just a lightweight helper that
9
+ * translates the postMessages thing to actual props and state that the user can control."*
10
+ *
11
+ * *** SO THERE IS NO `three` HERE AND THERE NEVER WILL BE. *** The design rule is *"there is no raw
12
+ * npm renderer, ever."* An integrator installing this installs React bindings and a URL builder. The
13
+ * whole of the 3D - the models, the shaders, the compositing, the 19.7 GB asset export - stays on our
14
+ * side of the frame, which is what makes the logo enforceable and what removes every peer-dependency
15
+ * question about three.js at once.
16
+ *
17
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
18
+ * *** THE RULE THIS FILE IS WRITTEN UNDER: nothing here may require knowing how SkinHub is built. ***
19
+ *
20
+ * Every name is either a word the CS2 economy already uses (`paintIndex`, `float`, `seed`,
21
+ * `defindex`, `statTrak`) or a word any 3D component uses (`view`, `camera`, `quality`, `bloom`).
22
+ * Nothing is named after a route, a store, a shader parameter or a file in our tree. A prop that only
23
+ * makes sense once you have read our source is a bug in this file.
24
+ *
25
+ * *** AND ONE COROLLARY WORTH STATING, because it is the difference between a wrapper and a leak: ***
26
+ * this file does NOT re-export `/frame`'s own vocabulary. The wire says `weaponType` and
27
+ * `PlacementSlots` in the game's field names (`sticker_id`, `offset_x`); an integrator says `weapon`
28
+ * and `{ id, offsetX }`. `item.ts` is the only place the two meet, and it is not exported for the
29
+ * public surface's sake - it is exported because a marketplace holding an inspect link needs the
30
+ * bridge in its own server code too.
31
+ */
32
+
33
+ import type { CSSProperties, ReactNode } from 'react'
34
+
35
+ import { LINK, type ViewerLink } from './link.js'
36
+ import type { WeaponId } from './weapons.js'
37
+
38
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
39
+ * THE ITEM
40
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
41
+
42
+ /**
43
+ * A sticker on one of the weapon's five slots.
44
+ *
45
+ * `offsetX`/`offsetY` are a DELTA on the slot's authored home, in the weapon's sticker UV space,
46
+ * clamped to +/-0.5 - the game's own `g_vStickerNOffset` range. Omitted means "where the weapon puts
47
+ * it", which is what an unmodified applied sticker looks like in game.
48
+ */
49
+ export type SkinViewerSticker = {
50
+ /** `sticker_id` - the id in `@skinhub/cdn`'s `stickers.json`. */
51
+ id: number
52
+ /**
53
+ * Which of the five slots, `0`..`4`. Omitted uses the sticker's position in the `stickers` array,
54
+ * which is what makes `stickers={[a, null, b]}` mean "slot 0 and slot 2".
55
+ */
56
+ slot?: 0 | 1 | 2 | 3 | 4
57
+ /** Scratch, `0` (mint) to `1` (scraped off). Default `0`. */
58
+ wear?: number
59
+ /** Degrees, any value; `370` renders as `10`. Default `0`. */
60
+ rotation?: number
61
+ /** -0.5..0.5. Default `0` - the slot's authored position. */
62
+ offsetX?: number
63
+ offsetY?: number
64
+ }
65
+
66
+ /** A charm hanging off the weapon's charm anchor. Knives have no anchor and ignore this. */
67
+ export type SkinViewerCharm = {
68
+ /** The id in `@skinhub/cdn`'s `keychains.json`. */
69
+ id: number
70
+ /**
71
+ * The charm's template - its `pattern` on the wire. It is not a variant: it drives a
72
+ * hue/saturation/brightness adjust on the charm's own albedo, so two charms with the same `id` and
73
+ * a different `seed` are the same model in different colours. Default `0`.
74
+ */
75
+ seed?: number
76
+ /**
77
+ * Displacement from the weapon's charm anchor, in the game's own `offset x/y/z`. Omitted hangs it
78
+ * where the weapon hangs it. Values outside the authored charm region are pulled back into it by
79
+ * the renderer rather than rejected.
80
+ *
81
+ * *** `readonly` IS AN INPUT CONVENIENCE AND AN OUTPUT NUISANCE, so it is worth stating: *** a
82
+ * mutable `[number, number, number]` assigns to this fine, which is what makes passing one in easy.
83
+ * Reading it back off an `onChange` item gives you the readonly type, so moving one axis needs a
84
+ * copy - `[...charm.offset]` - rather than an index write.
85
+ */
86
+ offset?: readonly [x: number, y: number, z: number]
87
+ }
88
+
89
+ /** The half of an item that is not its identity. Everything here updates IN PLACE - see {@link CHEAP_FIELDS}. */
90
+ type ItemConfiguration = {
91
+ /**
92
+ * The finish. `1449` is AK-47 | AUTOEXEC, `0` is the correct value for a VANILLA item and renders
93
+ * the bare model rather than an error.
94
+ */
95
+ paintIndex: number
96
+ /**
97
+ * Which mesh variant to load. Most finishes declare it themselves and this is then ignored; pass it
98
+ * when they do not, from `skin.legacy_model` on the `@skinhub/cdn` row you already have.
99
+ *
100
+ * IT IS IDENTITY AND NOT A DETAIL: both the weapon-space textures and the composite scale constants
101
+ * are authored per variant, so the wrong one is the wrong picture rather than a slightly different
102
+ * one. 1,182 of 2,161 catalogue rows are legacy.
103
+ */
104
+ legacyModel?: boolean
105
+ /**
106
+ * Wear, `0`..`1`. `paintwear` on the wire; every marketplace calls it the float.
107
+ *
108
+ * NOT EVERY FINISH RUNS 0..1. A kit declares its own `wear_remap_min`/`wear_remap_max` and the
109
+ * renderer CLAMPS into that range rather than remapping, so `0.9` on a kit that stops at `0.4`
110
+ * renders at `0.4`. If you are drawing a float slider, take the kit's real range from
111
+ * `@skinhub/cdn` rather than assuming 0..1.
112
+ *
113
+ * Omitted renders at the kit's own best condition, which is the flattering example of the item.
114
+ */
115
+ float?: number
116
+ /** The paint seed - which roll of the finish this is. Default `0`. */
117
+ seed?: number
118
+ /**
119
+ * The StatTrak counter's value, or `false` for an item with no counter.
120
+ *
121
+ * ONE FIELD RATHER THAN A BOOLEAN AND A COUNT, because that is what the item is: the game has no
122
+ * StatTrak-with-no-count. `0` is a real, legal, freshly-minted counter and is NOT the same as
123
+ * `false`. Default `false`.
124
+ */
125
+ statTrak?: number | false
126
+ /** The name plate's text, or `null`/omitted for no plate. Trimmed; the game shows 20 characters. */
127
+ nameTag?: string | null
128
+ /**
129
+ * Up to five stickers. Array position is the slot unless a sticker names its own; `null` leaves a
130
+ * slot empty, so `[a, null, b]` is slots 0 and 2.
131
+ */
132
+ stickers?: readonly (SkinViewerSticker | null)[]
133
+ /** The charm, or `null`/omitted for none. */
134
+ charm?: SkinViewerCharm | null
135
+ }
136
+
137
+ /**
138
+ * *** WHICH ITEM FIELDS UPDATE IN PLACE, WRITTEN DOWN AS A VALUE. ***
139
+ *
140
+ * The owner's requirement, verbatim: changing `float`, `seed`, `statTrak`, `nameTag`, the stickers or
141
+ * the charm *"must NOT reload the viewer - that's a must to make it feel just like in our website."*
142
+ * Changing the weapon or the paint kit *"shows nothing until the model has loaded, behind the loading
143
+ * card"*.
144
+ *
145
+ * This list is the FIRST half. It is here, as a value, because {@link SkinViewerProps.loading} is
146
+ * raised off its complement - see `SkinViewer.tsx`'s `isIdentityChange` - and because a contract an
147
+ * integrator relies on should be readable without opening the renderer.
148
+ */
149
+ export const CHEAP_FIELDS = ['float', 'seed', 'statTrak', 'nameTag', 'stickers', 'charm'] as const
150
+
151
+ /**
152
+ * *** WHICH WEAPON, SAID TWO WAYS, AND EXACTLY ONE OF THEM. ***
153
+ *
154
+ * An integrator holds a catalogue row (`weapon: 'weapon_ak47'`, which is `skin.weapon.id` on a
155
+ * `@skinhub/cdn` row) or a decoded inspect link (`defindex: 7`). Both are the AK-47.
156
+ *
157
+ * `?: never` ON THE OTHER MEMBER rather than a runtime check, for the same reason the top-level
158
+ * subject union uses it: an item naming a weapon two ways that disagree is a state with no sensible
159
+ * precedence rule, so it is made unrepresentable instead of arbitrated.
160
+ *
161
+ * `weapon` IS THE ONE TO REACH FOR. It needs no lookup and therefore cannot go stale; `defindex` is
162
+ * resolved against a checked-in 63-row table (see `weapons.ts`) and a number that table has never
163
+ * heard of reports `unknown-weapon` rather than rendering the wrong gun.
164
+ */
165
+ export type SkinViewerItem = ItemConfiguration &
166
+ (
167
+ | {
168
+ /**
169
+ * `'weapon_ak47'`, or a glove id (`'sporty_gloves'`). HUD aliases are accepted: `skins.json`
170
+ * gives the twenty VANILLA knife rows an `sfui_wpnhud_*` id, and that renders the knife here
171
+ * rather than failing, so `row.weapon.id` can be passed straight through.
172
+ */
173
+ weapon: WeaponId
174
+ defindex?: never
175
+ }
176
+ | {
177
+ /** `7` - the item definition index a decoded inspect link and a Steam inventory row carry. */
178
+ defindex: number
179
+ weapon?: never
180
+ }
181
+ )
182
+
183
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
184
+ * PRESENTATION
185
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
186
+
187
+ /**
188
+ * What the item is being shown ON. All three are the same item under the same lighting, finish,
189
+ * stickers and charm - the difference is the camera and what is holding the weapon.
190
+ *
191
+ * `gun` the item alone, orbitable, framed to the viewport. The default.
192
+ * `hands` CS2's first-person viewmodel, driven by the game's own clips. Orbit is off here because
193
+ * there is nothing to orbit: the weapon is welded to the eye.
194
+ * `agent` an operator holding the weapon, at conversational distance, orbitable.
195
+ *
196
+ * *** A VIEW CHANGE RELOADS. *** It rebuilds the rig, so it goes behind the loading card exactly as a
197
+ * weapon change does.
198
+ */
199
+ export type ViewerView = 'gun' | 'hands' | 'agent'
200
+
201
+ /** The person holding the weapon, in `hands` and `agent`. Ignored in `gun` - nobody is on screen. */
202
+ export type ViewerAgent = {
203
+ /** An agent's item definition index; `5036` is the default Terrorist and the default here. */
204
+ id?: number
205
+ /**
206
+ * Which main-menu performance they play, by clip leaf name, or `null` (the default) to let the
207
+ * WEAPON choose it - an AK gives the AK idle, an AWP re-poses on its own.
208
+ */
209
+ pose?: string | null
210
+ }
211
+
212
+ /**
213
+ * What the operator has on their hands, or `null` for their own default pair.
214
+ *
215
+ * *** `null` IS NOT "BARE HANDS". *** Every agent ships a pair of their own and that is what `null`
216
+ * gets you. There is no way to render an operator with no gloves, because the game has no such state.
217
+ */
218
+ export type ViewerGloves = {
219
+ /** `'sporty_gloves'`, `'specialist_gloves'`, … */
220
+ type: string
221
+ paintIndex: number
222
+ /** Default `0.06`, the kit's own best condition. */
223
+ float?: number
224
+ seed?: number
225
+ }
226
+
227
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
228
+ * SETTINGS
229
+ *
230
+ * *** FOUR GROUPS, EACH SHALLOW-MERGED OVER OUR DEFAULTS. *** decided
231
+ * yes: `settings={{ quality: { bloom: 0 } }}` keeps the camera, the environment and the other four
232
+ * quality fields. Every default is stated on the field, so a partial object is predictable rather
233
+ * than something you discover by removing keys until the picture changes.
234
+ *
235
+ * *** AND THE MERGE HAPPENS IN THE FRAME, NOT HERE, *** which is the only reason it can be trusted:
236
+ * `/frame` applies a patch through the same `applyFramePatch` that the URL goes through, so a value
237
+ * this package has never heard of and a value it sets are written to the same field by the same code.
238
+ * A second merge on this side would be a second set of defaults free to drift from the real ones.
239
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
240
+
241
+ /**
242
+ * *** THE MAPS, AS A CLOSED UNION. *** The owner asked for this directly: `background` is
243
+ * *"'transparent' or a real map id, not a free string"*.
244
+ *
245
+ * WHAT THAT COSTS, said out loud rather than discovered: this list is a COPY of the app's own, so a
246
+ * map added to the export after this package was published is not typeable until the package is
247
+ * upgraded. That is the deliberate trade - the rule is *"no back-compat
248
+ * boilerplate, upgrade when you need to"* - and the failure is legible either way: the frame reports
249
+ * an unknown map in its `problems` and keeps the lighting it had.
250
+ */
251
+ export const MAP_NAMES = [
252
+ 'Ancient',
253
+ 'Anubis',
254
+ 'Baggage',
255
+ 'Cache',
256
+ 'Dust II',
257
+ 'Inferno',
258
+ 'Italy',
259
+ 'Mirage',
260
+ 'Nuke',
261
+ 'Office',
262
+ 'Overpass',
263
+ 'Train',
264
+ 'Vertigo',
265
+ 'Warehouse',
266
+ ] as const
267
+
268
+ export type MapName = (typeof MAP_NAMES)[number]
269
+ export type TimeOfDay = 'Day' | 'Night'
270
+
271
+ /**
272
+ * What is BEHIND the item.
273
+ *
274
+ * *** `'transparent'` IS THE DEFAULT AND IT IS THE WHOLE POINT OF AN EMBED. *** The frame paints no
275
+ * background of its own and mounts no video element, so the canvas composites straight over your
276
+ * page. Naming a map instead shows that map's video plate, which is a 30-60 MB download.
277
+ *
278
+ * TWO THINGS THAT SURPRISE PEOPLE, both stated in `EMBED.md` §9: bloom outside the item's outline is
279
+ * additive light with no alpha, so the halo reads weaker over your page than over a map (which is why
280
+ * {@link ViewerQualitySettings.bloomSpill} ships at `0` here); and a transparent iframe is still not
281
+ * click-through - it takes pointer events over its whole rectangle.
282
+ */
283
+ export type ViewerBackground = 'transparent' | MapName
284
+
285
+ export type ViewerCameraSettings = {
286
+ /**
287
+ * Vertical field of view in degrees, `1`..`179`. Default `26` - a long lens, which is what keeps a
288
+ * rifle from looking bent. Changing it RE-FRAMES rather than zooms.
289
+ *
290
+ * Ignored in `hands`, which uses CS2's own `viewmodel_fov`. They are different quantities.
291
+ */
292
+ fov?: number
293
+ /**
294
+ * A MULTIPLIER on the solved fit distance, `0.05`..`20`. Default `1`; `1.2` is 20% closer.
295
+ *
296
+ * NEVER A DISTANCE, because a fixed distance cannot be right in two differently-shaped containers -
297
+ * the camera fits the item to the canvas aspect, so the same AK sits at a different distance in a
298
+ * 3:4 card than on a full-bleed page.
299
+ */
300
+ defaultZoom?: number
301
+ }
302
+
303
+ export type ViewerQualitySettings = {
304
+ /**
305
+ * CS2's bloom as a strength multiplier, `0`..`10`. Default `1` (the game's own look); `2` is heavy.
306
+ *
307
+ * *** `0` DISABLES IT OUTRIGHT *** - no post-processing chain is mounted and no offscreen target is
308
+ * allocated, so it is genuinely the cheaper path rather than the same path at zero strength. A page
309
+ * with a grid of viewers wants this off.
310
+ */
311
+ bloom?: number
312
+ /**
313
+ * How much of the bloom is allowed past the item's outline, `0`..`10`. **Default `0` in the embed**,
314
+ * where our own site ships `1`.
315
+ *
316
+ * The default differs on purpose: a halo spreading onto the surrounding page is a house look on a
317
+ * full-bleed showcase, and a product page's layout did not ask for light leaking out of the canvas
318
+ * box. Opt in. Ignored while {@link bloom} is `0`.
319
+ */
320
+ bloomSpill?: number
321
+ /**
322
+ * A CEILING on `devicePixelRatio`, `0.25`..`3`. Default `1.5`.
323
+ *
324
+ * *** THE SINGLE BIGGEST PERFORMANCE LEVER: `2` is four times the fragments of `1`. *** A page
325
+ * rendering a grid of viewers wants this low.
326
+ */
327
+ renderScale?: number
328
+ /**
329
+ * Multisampling. Default `true`.
330
+ *
331
+ * *** ONLY REACHABLE WHILE {@link bloom} IS ABOVE `0`. *** With bloom off the scene renders straight
332
+ * to the canvas, whose antialiasing is fixed when the WebGL context is created; so `bloom: 0` with
333
+ * `antialias: false` is not an error and is not ignored either - it is antialiased anyway.
334
+ */
335
+ antialias?: boolean
336
+ /**
337
+ * The item shadowing itself. Default `false`, which is the reference picture: the lighting rig was
338
+ * calibrated without it and there is no ground plane for a shadow to land on.
339
+ */
340
+ shadows?: boolean
341
+ }
342
+
343
+ export type ViewerEnvironmentSettings = {
344
+ /**
345
+ * WHICH MAP'S LIGHT. Default `'Ancient'`; `null` is our calibrated reference rig, which is what
346
+ * every fidelity measurement behind this renderer was taken against.
347
+ *
348
+ * *** THIS IS THE LIGHT, {@link background} IS THE PICTURE, AND THEY ARE SEPARATE ON PURPOSE. ***
349
+ * Naming a map lights your item with the probe and sun CS2 bakes into that map's own menu scene.
350
+ * The common embed is `{ map: 'Mirage' }` with the default transparent background: Mirage's light,
351
+ * your page behind it.
352
+ */
353
+ map?: MapName | null
354
+ /** Default `'Night'`. Falls back on its own for a map that has only one. */
355
+ timeOfDay?: TimeOfDay
356
+ /** Wet surfaces on maps whose own data says it rains. Default `true`. */
357
+ rain?: boolean
358
+ /** See {@link ViewerBackground}. Default `'transparent'`. */
359
+ background?: ViewerBackground
360
+ }
361
+
362
+ /**
363
+ * *** OVERLAYS ARE A CATEGORY, NOT ONE FLAG, and they are INDEPENDENT of dragging. ***
364
+ *
365
+ * The sticker outline is an editing AFFORDANCE, not part of the item. Both combinations are
366
+ * legitimate and both are supported: dragging on with the gizmo hidden (invisible hit targets, your
367
+ * own UI drawing the guides), and the gizmo shown with dragging off (show where a sticker sits, let
368
+ * nobody move it). They must not be collapsed into one prop, which is why they live in different
369
+ * groups - see {@link ViewerInteractions}.
370
+ */
371
+ export type ViewerOverlaySettings = {
372
+ /** The outline and handles on the OPEN sticker. Default `false`. */
373
+ stickerGizmo?: boolean
374
+ /** The charm's billboard handle. Default `false`. */
375
+ charmGizmo?: boolean
376
+ /** Colour and readability of the gizmo chrome, for a host with its own design. */
377
+ gizmoStyle?: {
378
+ /** Any CSS colour. */
379
+ color?: string
380
+ /** The dark under-stroke that keeps the chrome readable over a pale kit. */
381
+ shadowColor?: string
382
+ }
383
+ }
384
+
385
+ /**
386
+ * *** THE FOUR GROUPS ARE THE FOUR THE FRAME SPEAKS, AND NO MORE. ***
387
+ *
388
+ * The design notes also sketch `autoRotation` and `sound`. They are deliberately absent: the
389
+ * `/frame` protocol carries neither today, so a prop for them would be a prop that silently does
390
+ * nothing - which is worse than a missing feature, because it is a missing feature you cannot see.
391
+ * They arrive here in the same change that adds them to the wire, and the type is what forces that.
392
+ */
393
+ export type ViewerSettings = {
394
+ camera?: ViewerCameraSettings
395
+ quality?: ViewerQualitySettings
396
+ environment?: ViewerEnvironmentSettings
397
+ overlays?: ViewerOverlaySettings
398
+ }
399
+
400
+ /**
401
+ * WHAT THE USER MAY DO WITH THE MOUSE. Orbiting and zooming are on; EDITING IS OFF.
402
+ *
403
+ * Editing defaults to off because a viewer that silently let a visitor move a sticker would
404
+ * desynchronise the host's own database without warning. Owner's brief: the integrator adds charms in
405
+ * their own UI, and we let the user move stickers *"if they enabled that"*.
406
+ */
407
+ export type ViewerInteractions = {
408
+ /** Drag to turn the item, right-drag to pan. Default `true`. Always off in `hands`. */
409
+ orbit?: boolean
410
+ /** Wheel to dolly. Default `true`. */
411
+ zoom?: boolean
412
+ /**
413
+ * Let the user move and rotate a PLACED sticker. Default `false`.
414
+ *
415
+ * *** IF YOU TURN THIS ON, HANDLE {@link SkinViewerProps.onChange}, *** or you will not be able to
416
+ * save what your user did.
417
+ */
418
+ dragStickers?: boolean
419
+ /** The same, for the charm. Default `false`. */
420
+ dragCharm?: boolean
421
+ }
422
+
423
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
424
+ * FAILURE
425
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
426
+
427
+ export type SkinViewerErrorCode =
428
+ /** A model, texture or index file failed to load, or the GL context was lost. UNRECOVERABLE. */
429
+ | 'render-failed'
430
+ /** The `inspectLink` did not decode - a bad CRC, a truncated body, or not a masked link at all. */
431
+ | 'bad-inspect-link'
432
+ /** `defindex` named a weapon this build has no id for. Pass `weapon` instead, or upgrade. */
433
+ | 'unknown-weapon'
434
+ /** Neither `inspectLink` nor `item` arrived at runtime. See {@link SkinViewerProps}. */
435
+ | 'no-item'
436
+ /**
437
+ * *** THE EMBED NEVER ANSWERED. *** Nothing at {@link SkinViewerProps.origin} announced itself
438
+ * within {@link CONNECT_TIMEOUT_MS}, so there is no conversation and nothing has rendered.
439
+ *
440
+ * *** IT EXISTS BECAUSE THE BROWSER WILL NOT TELL US OTHERWISE. *** A cross-origin `<iframe>` fires
441
+ * `load` for a 404, a 500 and a corporate block page exactly as it does for the real thing, fires no
442
+ * `error` for any of them, and its document cannot be read. Without a timer the component would sit
443
+ * at `'connecting'` for ever showing an empty box - which is the single worst failure an integrator
444
+ * can be handed, because there is nothing at all to search for.
445
+ *
446
+ * The likely causes, in the order they actually happen: a network or corporate proxy blocking the
447
+ * origin, an `origin` prop pointing somewhere that does not serve `/frame`, or a
448
+ * `Content-Security-Policy` on the HOST page whose `frame-src` does not allow it.
449
+ *
450
+ * *** NOT PERMANENT. *** The iframe stays mounted underneath your `fallback`, so a slow connection
451
+ * that arrives late clears this by itself. `reload()` retries deliberately.
452
+ */
453
+ | 'unreachable'
454
+ /**
455
+ * A field was rejected on the way in and DROPPED, naming its path. Not fatal: the field keeps its
456
+ * previous value and the rest of the update is applied. Nothing is ever coerced - `float: '0.3'` is
457
+ * rejected rather than parsed.
458
+ */
459
+ | 'bad-message'
460
+ /**
461
+ * *** THE ONE TERMINAL FAILURE. *** This package and the embed do not speak the same protocol
462
+ * version, so the frame has rendered NOTHING - no canvas, no partial picture - and will ignore
463
+ * every later message. The message names which side is out of date. A
464
+ * silently wrong render is worse than a blank frame with an explanation.
465
+ */
466
+ | 'protocol-mismatch'
467
+
468
+ export type SkinViewerError = {
469
+ code: SkinViewerErrorCode
470
+ /** A sentence a developer can act on. Safe to show in a dev overlay; not localised. */
471
+ message: string
472
+ }
473
+
474
+ /** The box the frame reported, on the `resize` event. See {@link SkinViewerProps.onResize}. */
475
+ export type ViewerResize = { width: number; height: number; dpr: number }
476
+
477
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
478
+ * THE COMPONENT
479
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
480
+
481
+ /**
482
+ * *** EXACTLY ONE OF `inspectLink` OR `item`, ENFORCED IN THE TYPES. ***
483
+ *
484
+ * Owner's requirement, and it is a TYPE requirement rather than a runtime one: *"a discriminated
485
+ * union, so a missing or doubled item is a compile error, not a runtime surprise."*
486
+ *
487
+ * `?: never` on the other member is what does it. Passing both is an excess-property error at the
488
+ * call site; passing neither is a missing-property error. An integrator holds one or the other - a
489
+ * link out of a trade offer, or the fields already in their database - and the state where both are
490
+ * present and disagree is one this package would have to invent a precedence rule for. There is no
491
+ * rule; there is no such call.
492
+ *
493
+ * *** AND WHEN THE TYPES ARE BYPASSED, THE VIEWER TEACHES. *** Plain JavaScript, an `any`, data that
494
+ * was still `undefined` when the component mounted: the frame renders a short instruction card rather
495
+ * than a blank box or - worse - our default AK-47, which would look like a successful render of the
496
+ * wrong item. `onError` fires with `no-item` at the same time. See `SkinViewer.tsx`.
497
+ */
498
+ export type ViewerSubject = { inspectLink: string; item?: never } | { item: SkinViewerItem; inspectLink?: never }
499
+
500
+ export type SkinViewerProps = ViewerSubject & {
501
+ /* ── Presentation ──────────────────────────────────────────────────────────────────────── */
502
+ /** Default `'gun'`. See {@link ViewerView}. */
503
+ view?: ViewerView
504
+ /** Who is holding it, in the two views that have somebody holding it. See {@link ViewerAgent}. */
505
+ agent?: ViewerAgent
506
+ /** What they have on their hands, or `null` for their own default pair. See {@link ViewerGloves}. */
507
+ gloves?: ViewerGloves | null
508
+ /** Four groups, shallow-merged per group over our defaults. See {@link ViewerSettings}. */
509
+ settings?: ViewerSettings
510
+ /** See {@link ViewerInteractions}. Orbiting and zooming are on, editing is off. */
511
+ interactions?: ViewerInteractions
512
+
513
+ /* ── Selection ─────────────────────────────────────────────────────────────────────────── */
514
+ /**
515
+ * Which slot (`0`..`4`, or `5` for the charm) has its handles open, or `-1` for none.
516
+ *
517
+ * CONTROLLED WHEN PASSED, UNCONTROLLED WHEN NOT. The uncontrolled case is the one most integrators
518
+ * want: with `interactions.dragStickers` on, a click opens the sticker and this never appears in
519
+ * their code. Pass it when your own UI has a slot list that has to stay in step.
520
+ */
521
+ editingSlot?: number
522
+ /** Fires when the USER opens a slot by clicking a sticker or the charm. */
523
+ onEditingSlotChange?: (slot: number) => void
524
+
525
+ /* ── Lifecycle ─────────────────────────────────────────────────────────────────────────── */
526
+ /**
527
+ * Fires when the viewer has STOPPED LOADING - the finish has reached the GPU and the item is on
528
+ * screen textured.
529
+ *
530
+ * *** IT IS A LEVEL, NOT AN EDGE. *** It fires again after every reload, because a weapon change or
531
+ * a view change raises the loading gate and lowers it again. It also fires if the render FAILED,
532
+ * because that is equally the moment to take your own placeholder down; use {@link onError} to tell
533
+ * the two apart.
534
+ */
535
+ onReady?: () => void
536
+ /** See {@link SkinViewerError}. */
537
+ onError?: (error: SkinViewerError) => void
538
+ /**
539
+ * The user moved a sticker or the charm - which only happens with `interactions.dragStickers` or
540
+ * `.dragCharm`. Fires on every pointer move during a drag.
541
+ *
542
+ * *** IT HANDS BACK A COMPLETE ITEM, NOT A DIFF. *** Store it verbatim and pass it straight back in
543
+ * as `item`. It never fires for your own prop changes; it is only ever the user talking.
544
+ *
545
+ * The returned item is always in the `{ weapon }` form, never `{ defindex }`, because that is what
546
+ * the frame reports and translating it back to a number would be a lookup that can fail.
547
+ */
548
+ onChange?: (item: SkinViewerItem) => void
549
+ /**
550
+ * The frame's own box changed, throttled to one per animation frame.
551
+ *
552
+ * You own the box and the viewer fills it - but RESIZING CHANGES THE PICTURE, not just its scale:
553
+ * the camera fits the item to the canvas aspect, so animating a panel open beside the frame
554
+ * re-frames the item.
555
+ */
556
+ onResize?: (size: ViewerResize) => void
557
+
558
+ /* ── Box ───────────────────────────────────────────────────────────────────────────────── */
559
+ /** On the wrapper element, which is `position: relative` and fills whatever you give it. */
560
+ className?: string
561
+ style?: CSSProperties
562
+ /** The `<iframe>`'s accessible name. Default `'SkinHub viewer'`; pass the item's name. */
563
+ title?: string
564
+ /**
565
+ * *** DRAWN OVER THE IFRAME, NOT INSIDE IT, AND CLEARED ON `ready`. ***
566
+ *
567
+ * A React element cannot cross a `postMessage` boundary - there is no way to hand a node to a
568
+ * document on another origin - so this is composited on YOUR side, absolutely positioned over the
569
+ * frame. That is the honest implementation and it has one consequence worth knowing: an opaque node
570
+ * hides the frame's own loading card rather than replacing it.
571
+ *
572
+ * Omitted falls through to the frame's own, which is a reasonable default and is what the SkinHub
573
+ * app shows.
574
+ */
575
+ loading?: ReactNode
576
+ /**
577
+ * Shown OVER the frame when the viewer cannot render - see {@link SkinViewerError}. A function gets
578
+ * the error, so you can tell a stale package apart from a lost GL context.
579
+ *
580
+ * Omitted leaves the frame's own card, which names the failure.
581
+ */
582
+ fallback?: ReactNode | ((error: SkinViewerError) => ReactNode)
583
+
584
+ /* ── Escape hatches ────────────────────────────────────────────────────────────────────── */
585
+ /**
586
+ * Where `/frame` is served from. Default `'https://skinhub.gg'`.
587
+ *
588
+ * *** READ ONCE, AT MOUNT. *** Changing it later does nothing, deliberately: it is the one prop that
589
+ * could only be applied by reloading the frame, and a prop that quietly throws away the GL context
590
+ * is exactly the thing this component is built not to have. Call `reload()` from
591
+ * {@link useSkinViewer} if you really need to move a mounted viewer to another origin.
592
+ */
593
+ origin?: string
594
+ /**
595
+ * The imperative handle from {@link useSkinViewer}. Props are state; the hook is verbs.
596
+ *
597
+ * A PLAIN PROP AND NOT `ref`, because `ref` on a component means "give me the DOM node" to every
598
+ * React developer alive, and handing them a `{ reload }` object instead of the `<div>` they asked
599
+ * for would be a surprise in the one place surprises are least welcome.
600
+ */
601
+ handle?: SkinViewerHandle
602
+ }
603
+
604
+ /* ═════════════════════════════════════════════════════════════════════════════════════════════
605
+ * THE HANDLE
606
+ * ═══════════════════════════════════════════════════════════════════════════════════════════ */
607
+
608
+ /** What the viewer is doing, as {@link useSkinViewer} reports it. */
609
+ export type ViewerStatus =
610
+ /** The frame has not announced itself yet. */
611
+ | 'connecting'
612
+ /** Loading an item - the first one, or a new one after an identity change. */
613
+ | 'loading'
614
+ /** On screen and textured. */
615
+ | 'ready'
616
+ /** An unrecoverable failure; `error` is set. */
617
+ | 'error'
618
+
619
+ /**
620
+ * *** PROPS FOR STATE, A HOOK FOR VERBS. *** See {@link useSkinViewer}.
621
+ *
622
+ * `reload` is stable for the life of the hook. THE HANDLE OBJECT IS NOT - it is rebuilt when
623
+ * {@link status}, {@link error} or {@link problems} move, because that is how a React value
624
+ * re-renders the tree reading it. Put `viewer.reload` in a dependency array, not `viewer`.
625
+ */
626
+ export type SkinViewerHandle = {
627
+ /** The private channel to the mounted component. See `link.ts`. */
628
+ readonly [LINK]: ViewerLink
629
+ /**
630
+ * Tear the frame down and load it again from the item that is on screen NOW.
631
+ *
632
+ * *** THE ONLY THING IN THIS PACKAGE THAT REMOUNTS THE IFRAME, AND IT IS DELIBERATELY IMPERATIVE. ***
633
+ * Nothing you can pass as a prop reloads the viewer - that is the whole cheap-update contract - so
634
+ * the escape hatch for "the GL context died" or "the assets were re-published" has to be a verb.
635
+ */
636
+ reload: () => void
637
+ /** What the viewer is doing. See {@link ViewerStatus}. */
638
+ status: ViewerStatus
639
+ /** The last unrecoverable failure, or `null`. */
640
+ error: SkinViewerError | null
641
+ /**
642
+ * Anything the frame could not read in the URL this package built for it.
643
+ *
644
+ * *** AN ENTRY HERE IS OUR BUG, NOT YOURS. *** You passed props; this package turned them into a
645
+ * query string; the frame is telling us which part of that string it did not accept. It is exposed
646
+ * rather than swallowed because the alternative is a picture that is quietly missing a field.
647
+ */
648
+ problems: readonly string[]
649
+ }
650
+
651
+ /*
652
+ * *** THERE IS NO `capture()`, AND THAT IS A MEASURED DECISION RATHER THAN A MISSING FEATURE. ***
653
+ *
654
+ * Asked for as *"yes IF IT IS CHEAP, 100% optional"*. It is not cheap. The renderer's
655
+ * `<Canvas>` runs with `preserveDrawingBuffer: false`, so a `toDataURL` taken from outside the render
656
+ * loop returns a blank image; shipping a real one means the renderer draws and reads inside a single
657
+ * frame, which is a change to a shared file in the app rather than anything this package can do. The
658
+ * frame's protocol has no `capture` verb for the same reason.
659
+ *
660
+ * *** AND NOTE WHAT IT WOULD NOT SOLVE EVEN IF IT WERE FREE: *** thumbnails for a listing grid and
661
+ * preview images for shared links both need a render that happens without a browser tab open. That is
662
+ * a server-side product and is explicitly not in scope.
663
+ */
664
+
665
+ /*
666
+ * *** AND THERE IS NO `apiKey`. NOT NOW, NOT LATER. ***
667
+ *
668
+ * Decided: *"No, never. Anyone can copy an embed URL anyway, so a key
669
+ * buys nothing."* The viewer is free for everyone. It is recorded here, in the type surface, because
670
+ * the cost of adding one later is a breaking change for every integrator - so the absence has to be a
671
+ * decision somebody can read, not an omission somebody might fix.
672
+ */