@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/EMBED.md ADDED
@@ -0,0 +1,442 @@
1
+ # Embedding the SkinHub viewer
2
+
3
+ The viewer is a page on our origin that you put in an `<iframe>`. There is nothing to install, no key,
4
+ no account, and no signup. Two ways to drive it:
5
+
6
+ | | what it needs from you | what you get |
7
+ |---|---|---|
8
+ | **The URL** | an `<iframe src>` | the complete initial picture: item, float, seed, stickers, charm, view, agent, gloves, lighting, quality |
9
+ | **`postMessage`** | ~10 lines of JavaScript | live updates on top of that, plus `ready` / `error` / `change` events |
10
+
11
+ **The URL alone is a supported, first-class integration.** If your stack is PHP, Rails, Laravel, plain
12
+ HTML or anything else that renders on a server, you can interpolate an item into an `src` attribute
13
+ and be finished. Everything on this page above the "Live updates" heading works with JavaScript
14
+ disabled.
15
+
16
+ ---
17
+
18
+ ## 1. The smallest thing that works
19
+
20
+ ```html
21
+ <iframe
22
+ src="https://skinhub.gg/frame?weapon=weapon_ak47&paint=1449"
23
+ style="width: 640px; height: 420px; border: 0; background: transparent"
24
+ title="AK-47 | AUTOEXEC"
25
+ ></iframe>
26
+ ```
27
+
28
+ With no parameters at all you get AK-47 | AUTOEXEC. That is the smoke test, not your item - if you see
29
+ it when you did not ask for it, your identity parameters did not resolve. See
30
+ [what the frame tells you](#8-debugging-a-url).
31
+
32
+ ---
33
+
34
+ ## 2. Naming the item
35
+
36
+ Three ways in. Pick whichever matches what your database already holds.
37
+
38
+ ### `?weapon=` + `?paint=` - the model key and the paint index
39
+
40
+ ```
41
+ ?weapon=weapon_ak47&paint=1449
42
+ ?weapon=weapon_awp&paint=344
43
+ ?weapon=weapon_knife_karambit&paint=413
44
+ ?weapon=sporty_gloves&paint=10038
45
+ ?weapon=weapon_ak47&paint=0 ← vanilla, no finish
46
+ ```
47
+
48
+ The fastest and the most reliable: no catalogue lookup, no name matching, nothing that can go stale.
49
+ `paint=0` is a real value and renders the bare model. Gloves are a legitimate subject and use the same
50
+ two parameters.
51
+
52
+ ### `?weapon=` + `?skin=` - our own slugs
53
+
54
+ ```
55
+ ?weapon=ak-47&skin=asiimov
56
+ ?weapon=awp&skin=dragon-lore
57
+ ```
58
+
59
+ The pair from our own item URLs. Underscores mean a model key, hyphens mean a slug, so the two forms
60
+ cannot be confused.
61
+
62
+ ### `?hash=` - Steam's `market_hash_name`
63
+
64
+ ```
65
+ ?hash=AK-47%20%7C%20Asiimov
66
+ ?hash=%E2%98%85%20Karambit%20%7C%20Fade
67
+ ```
68
+
69
+ Drop the exterior and any `StatTrak™` / `Souvenir` prefix; keep the `★`. For an integrator whose data
70
+ came out of a Steam inventory and has no paint index in it.
71
+
72
+ One name can be several items - the Dopplers are eight rows called `★ Karambit | Doppler`, one per
73
+ phase, and the phase lives only in the paint index. `?hash=` takes the lowest paint index of the
74
+ candidates. **If you know the phase, send `?paint=` as well; it wins.**
75
+
76
+ ### `?legacy=1`
77
+
78
+ Loads the legacy mesh variant. Most finishes declare this themselves and it is ignored; pass it for
79
+ those that do not, from your catalogue's `legacy_model` column. It changes which textures apply, so
80
+ the wrong value is the wrong picture rather than a detail.
81
+
82
+ ---
83
+
84
+ ## 3. Configuring the item
85
+
86
+ ### `?i=` - everything at once, from an inspect link
87
+
88
+ ```
89
+ ?weapon=weapon_ak47&paint=1449&i=001800200038D7B6C5F1034095054800...
90
+ ```
91
+
92
+ `?i=` carries float, seed, StatTrak and its count, the name plate, all five stickers with their
93
+ offsets, rotation, scale and wear, and the charm with its own pattern - encoded exactly as CS2's own
94
+ inspect link encodes them. **If you hold an inspect link, this is the parameter to use.** It
95
+ round-trips exactly: what you put in is what comes back out.
96
+
97
+ A whole link is accepted, not just the hex:
98
+
99
+ ```
100
+ ?i=steam%3A%2F%2Frungame%2F730%2F.....%2F%2Bcsgo_econ_action_preview%20001800...
101
+ ```
102
+
103
+ `?i=` does **not** name the weapon. Send `?weapon=`/`?paint=` alongside it.
104
+
105
+ ### The same fields as plain values
106
+
107
+ For data that lives in your own columns:
108
+
109
+ | parameter | meaning |
110
+ |---|---|
111
+ | `float=0.2738` | wear, `0`..`1`. Omitted renders at the kit's own best condition, which is the flattering example of the item |
112
+ | `seed=661` | paint seed |
113
+ | `st=1337` | StatTrak counter. `st=0` is a real, freshly-minted counter; `st=-1` removes the module; omitted means no module |
114
+ | `nametag=SHAREME` | the name plate. Empty or whitespace removes it |
115
+
116
+ Not every finish runs `0`..`1`. A kit declares its own wear range and the renderer clamps into it, so
117
+ `float=0.9` on a kit that stops at `0.5` renders at `0.5` rather than failing.
118
+
119
+ **Order matters in exactly one place:** a plain `float=` / `seed=` / `st=` / `nametag=` beats the same
120
+ field inside `?i=`, because the explicit one is the more specific statement.
121
+
122
+ ---
123
+
124
+ ## 4. How it is shown
125
+
126
+ | parameter | values | default |
127
+ |---|---|---|
128
+ | `view` | `gun`, `hands`, `agent` (`viewmodel` is accepted for `hands`) | `gun` |
129
+ | `agent` | an agent id, e.g. `5203` | `5036` (Default T) |
130
+ | `pose` | a main-menu clip leaf name | matches the weapon |
131
+ | `glove` | `type:paintIndex[:float[:seed]]`, e.g. `sporty_gloves:10038:0.31:77`. `none` for the wearer's own default pair | the wearer's own pair |
132
+ | `slot` | `0`..`4` for a sticker, `5` for the charm, `-1` for none - which one opens with its handles showing | `-1` |
133
+
134
+ `agent`, `pose` and `glove` are inert in the `gun` view; there is nobody on screen to wear them.
135
+
136
+ **Omitting `glove` is not "bare hands".** Every agent ships a default pair of their own, and that is
137
+ what you get.
138
+
139
+ ---
140
+
141
+ ## 5. Looks and performance
142
+
143
+ ### Lighting and background
144
+
145
+ | parameter | values | default |
146
+ |---|---|---|
147
+ | `map` | a map name (`Ancient`, `Mirage`, `Nuke`, ...), or `none` for our calibrated reference rig | `Ancient` |
148
+ | `time` | `Day`, `Night`. Falls back when a map has only one | `Night` |
149
+ | `rain` | `0` / `1` - wet surfaces on maps whose own data says it rains | `1` |
150
+ | `bg` | `transparent`, or a map name to show that map's video plate | `transparent` |
151
+
152
+ **`map` is the light, `bg` is the picture behind it, and they are separate on purpose.** Naming a map
153
+ lights your item with the probe and sun CS2 bakes into that map's own menu scene. The common embed is
154
+ `?map=Mirage` with the default transparent background: Mirage's light, your page behind it.
155
+
156
+ `bg=transparent` mounts no video element at all, so nobody downloads a 30-60 MB file they cannot see.
157
+
158
+ ### Quality
159
+
160
+ | parameter | values | default | notes |
161
+ |---|---|---|---|
162
+ | `bloom` | `0` disables it entirely, `1` is CS2's own look, `2` is strong | `1` | `0` mounts no post-processing chain and allocates no offscreen target, so it is genuinely the cheaper path |
163
+ | `spill` | `0`..`n` - how much bloom is allowed past the item's outline | `0` | our own site ships `1`; the embed ships `0`, because a halo leaking onto your product page is not something your layout asked for |
164
+ | `scale` | `0.25`..`3`, or `Performance` / `Balanced` / `Native` | `1.5` | a **ceiling** on `devicePixelRatio`. The single biggest performance lever - `2` is four times the fragments of `1`. **A page with a grid of viewers wants this low.** |
165
+ | `aa` | `0` / `1` | `1` | only reachable while `bloom` is above `0`; with bloom off the canvas antialiases itself and cannot be changed |
166
+ | `shadows` | `0` / `1` | `0` | the item shadowing itself. Off is the reference picture |
167
+ | `fov` | `1`..`179` degrees | `26` | a long lens, which is what keeps a rifle from looking bent. Changing it re-frames rather than zooms. Ignored in `hands`, which uses CS2's own `viewmodel_fov` |
168
+ | `zoom` | `0.05`..`20` | `1` | a **multiplier** on the solved fit distance, never a distance - `1.2` is 20% closer. A fixed distance cannot be right in two differently-shaped containers |
169
+
170
+ ### What the user may do
171
+
172
+ | parameter | default | |
173
+ |---|---|---|
174
+ | `orbit` | `1` | drag to turn the item, right-drag to pan. Always off in `hands` |
175
+ | `wheel` | `1` | wheel to dolly |
176
+ | `dragstickers` | `0` | let the user move and rotate a placed sticker |
177
+ | `dragcharm` | `0` | the same for the charm |
178
+ | `stickergizmo` | `0` | draw the outline and handles on the open sticker |
179
+ | `charmgizmo` | `0` | draw the charm's billboard |
180
+ | `gizmocolor` | - | any CSS colour for the gizmo chrome |
181
+ | `gizmoshadow` | - | the dark under-stroke that keeps it readable over a pale kit |
182
+
183
+ **Editing is off by default and dragging and drawing are independent.** Enabling `dragstickers`
184
+ without `stickergizmo` gives invisible hit targets, for a host drawing its own guides. Enabling
185
+ `stickergizmo` without `dragstickers` shows where a sticker sits and does not let anyone move it. Both
186
+ combinations are supported.
187
+
188
+ **If you turn dragging on, listen for the `change` event** (below), or you will not be able to save
189
+ what your user did.
190
+
191
+ ---
192
+
193
+ ## 6. Live updates
194
+
195
+ Everything above can also be changed at runtime, without reloading the frame.
196
+
197
+ ```html
198
+ <iframe id="viewer" src="https://skinhub.gg/frame?weapon=weapon_ak47&paint=1449"></iframe>
199
+ <script>
200
+ const FRAME_ORIGIN = 'https://skinhub.gg'
201
+ const viewer = document.getElementById('viewer')
202
+
203
+ const set = patch =>
204
+ viewer.contentWindow.postMessage(
205
+ { channel: 'skinhub-viewer', v: 1, from: 'host', type: 'set', patch },
206
+ FRAME_ORIGIN,
207
+ )
208
+
209
+ window.addEventListener('message', event => {
210
+ if (event.source !== viewer.contentWindow) return
211
+ const message = event.data
212
+ if (!message || message.channel !== 'skinhub-viewer' || message.from !== 'viewer') return
213
+
214
+ switch (message.type) {
215
+ case 'hello': console.log('viewer listening, protocol', message.v, message.state, message.problems); break
216
+ case 'ready': console.log('finished loading'); break
217
+ case 'error': console.error(message.error.code, message.error.message); break
218
+ case 'change': saveToYourDatabase(message.item); break
219
+ }
220
+ })
221
+
222
+ // live, no reload:
223
+ document.querySelector('#float').oninput = e => set({ item: { float: +e.target.value } })
224
+ </script>
225
+ ```
226
+
227
+ ### The envelope
228
+
229
+ Every message in both directions carries these three fields:
230
+
231
+ ```js
232
+ { channel: 'skinhub-viewer', v: 1, from: 'host' | 'viewer', type: ... }
233
+ ```
234
+
235
+ - `channel` - anything without it is not ours and is ignored in silence. `postMessage` is a shared bus;
236
+ React DevTools, HMR and browser extensions all post into frames.
237
+ - `v` - the protocol version. See [versioning](#7-versioning).
238
+ - `from` - the direction. `host` for messages you send, `viewer` for messages you receive.
239
+
240
+ ### Host → viewer
241
+
242
+ | type | payload | |
243
+ |---|---|---|
244
+ | `set` | `patch` | any subset of the state. See below |
245
+ | `hello` | - | optional. Asks the viewer to announce itself again. Useful if you attached your listener after the frame had already loaded |
246
+
247
+ ### The patch
248
+
249
+ ```js
250
+ set({
251
+ item: { weaponType, paintIndex, legacyModel, float, seed, statTrak, nameTag, stickers },
252
+ view: 'gun' | 'hands' | 'agent',
253
+ agent: { id, pose },
254
+ gloves: { type, paintIndex, float, seed } | null,
255
+ settings: {
256
+ camera: { fov, defaultZoom },
257
+ quality: { bloom, bloomSpill, renderScale, antialias, shadows },
258
+ environment: { map, timeOfDay, rain, background },
259
+ overlays: { stickerGizmo, charmGizmo, gizmoStyle: { color, shadowColor } },
260
+ },
261
+ interactions: { orbit, zoom, dragStickers, dragCharm },
262
+ editingSlot: -1,
263
+ })
264
+ ```
265
+
266
+ **Everything merges by field. A key you leave out is left alone.** `set({ item: { float: 0.3 } })`
267
+ keeps your weapon, your stickers and your name plate. `set({ settings: { quality: { bloom: 0 } } })`
268
+ keeps the camera and the environment. This is not just convenience - see
269
+ [cheap vs reload](#cheap-vs-reload).
270
+
271
+ **`null` is a value, not an absence**, wherever it means something: `nameTag: null` is no plate,
272
+ `statTrak: false` is no counter, `gloves: null` is the wearer's own default pair, `agent.pose: null` is
273
+ "match the weapon", `settings.environment.map: null` is our calibrated reference rig.
274
+
275
+ **Nothing is coerced.** `float: '0.3'` is rejected, not parsed. You get an `error` event naming the
276
+ field, the field keeps its previous value, and the rest of the patch is applied.
277
+
278
+ ### Viewer → host
279
+
280
+ | type | payload | when |
281
+ |---|---|---|
282
+ | `hello` | `state`, `problems` | on mount, and in reply to your `hello`. `state` is everything the viewer resolved; `problems` lists any URL parameter it could not read |
283
+ | `ready` | - | the item is on screen and textured |
284
+ | `error` | `error: { code, message }` | see below |
285
+ | `change` | `item` | the user moved a sticker or the charm. Fires on every pointer move during a drag |
286
+ | `editing-slot` | `slot` | the user clicked a sticker or the charm to open it |
287
+ | `resize` | `width`, `height`, `dpr` | the frame's own box changed, throttled to one per animation frame |
288
+
289
+ `error` codes: `render-failed`, `bad-inspect-link`, `bad-message`, `protocol-mismatch`.
290
+
291
+ **`ready` fires again after every reload, not once per page.** It means "stopped loading", so a weapon
292
+ change or a view change raises the loading gate and lowers it again. Treat it as a level, not an edge.
293
+
294
+ **`hello` may arrive more than once.** It is idempotent - it carries the whole state - so read it as
295
+ "the viewer is listening", not "the viewer has just started".
296
+
297
+ **`change` hands back a complete item, not a diff.** Store it verbatim and pass it straight back in.
298
+ It does not fire for your own `set` calls; it is only ever the user talking.
299
+
300
+ ### Cheap vs reload
301
+
302
+ This is the part that decides whether the embed feels like a component or like an iframe.
303
+
304
+ | changing this | |
305
+ |---|---|
306
+ | `float`, `seed`, `statTrak`, `nameTag`, stickers, the charm, **anything under `settings` or `interactions`** | **updates in place.** No loading card, no blank frame, no dropped frames. Drive it from a slider at 60 Hz |
307
+ | `item.weaponType`, `item.paintIndex`, `item.legacyModel`, `view`, and `agent.id` in the `agent` view | **reloads.** The viewer covers itself until the new model has actually drawn, and shows nothing rather than something half-built |
308
+
309
+ Measured across the frame boundary: 140 `set` messages carrying a moving float, plus seed, StatTrak,
310
+ a name plate, a bloom change and a map change, produced **zero covered frames** over 93 drawn frames
311
+ and 979 samples.
312
+
313
+ Two consequences for you:
314
+
315
+ - **Send partial patches.** `set({ item: { float } })` is cheap; a patch that restates `weaponType`
316
+ with the same value is also cheap, but one that restates it with a *different* value reloads.
317
+ - **A patch that changes nothing costs nothing.** Re-sending an identical value does not even
318
+ re-render.
319
+
320
+ ---
321
+
322
+ ## 7. Versioning
323
+
324
+ `v` is an integer. **If it does not match the version the embed speaks, the viewer renders nothing** -
325
+ no canvas, no partial picture - and sends you an `error` with code `protocol-mismatch` and a sentence
326
+ saying which side is out of date. It does not recover; later messages are ignored.
327
+
328
+ That is deliberate, and it is the one place we are strict. A viewer that half-understood a message
329
+ would render a half-correct item, and a subtly wrong picture is worse than a blank one with an
330
+ explanation.
331
+
332
+ **A raw `<iframe src>` can never hit this.** It never sends a message, so it has no version to
333
+ disagree about. Only a host that talks to the frame can be out of date.
334
+
335
+ ---
336
+
337
+ ## 8. Debugging a URL
338
+
339
+ A parameter the viewer cannot read is **dropped and named**; it is never repaired or guessed at, and it
340
+ never blanks the viewer. The list arrives on the `hello` event as `problems`:
341
+
342
+ ```
343
+ ?float=banana&view=sideways&map=Atlantis&time=Dusk&scale=99&glove=nonsense
344
+ ```
345
+
346
+ ```js
347
+ problems: [
348
+ '?float=banana: expected a number in [0, 1]',
349
+ '?view=sideways: expected gun, hands or agent',
350
+ '?glove=nonsense: expected type:paintIndex[:float[:seed]]',
351
+ '?scale=99: expected a number in [0.25, 3] or Performance/Balanced/Native',
352
+ '?map=Atlantis: unknown map',
353
+ '?time=Dusk: expected Day or Night',
354
+ ]
355
+ ```
356
+
357
+ Booleans follow one rule everywhere: **`0` is off, anything else present is on, absent means "leave it
358
+ alone".**
359
+
360
+ ### `?help=` - ask the frame to explain itself
361
+
362
+ ```
363
+ ?help=1 ← "no item was passed", with the two ways to pass one
364
+ ?help=bad-link ← "that inspect link did not decode"
365
+ ?help=unknown-weapon ← "the item decoded, but its defindex is not one this build knows"
366
+ ```
367
+
368
+ Renders a short instruction card **instead of the viewer** - no scene, no WebGL context - and nothing
369
+ else. It exists because a bare `/frame` renders AK-47 | AUTOEXEC, which is the right answer for
370
+ somebody typing a URL by hand and the wrong one for a program that meant to name an item and did not:
371
+ our default weapon on screen looks like a successful render of the wrong item.
372
+
373
+ **The card clears itself** on the first `set` that carries `item.weaponType` or `item.paintIndex`, so
374
+ a host whose data arrives late can boot with `?help=1` and send the item when it turns up. That is
375
+ exactly what `@skinhub/viewer` does, and you can do it by hand for the same reason.
376
+
377
+ ---
378
+
379
+ ## 9. Sizing, transparency and layout
380
+
381
+ **You own the box; the viewer fills it.** Size the `<iframe>` however your layout wants. There is no
382
+ aspect lock and no minimum.
383
+
384
+ **Resizing changes the picture, not just its scale.** The camera fits the item to the canvas aspect, so
385
+ the same AK is framed differently in a 3:4 card than on a full-bleed page. Animating a panel open
386
+ beside the frame re-frames the item; listen for `resize` if you need to know the box it landed on.
387
+
388
+ **Transparency works.** `bg=transparent` is the default: the embed paints no background of its own and
389
+ the canvas composites over your page. Give the `<iframe>` `background: transparent` and no border.
390
+
391
+ Two things worth knowing before you discover them:
392
+
393
+ - **Bloom outside the item's outline is additive light with no alpha.** Over a transparent background
394
+ there is nothing for it to add to, so the halo reads weaker than it does over a map. `spill` ships at
395
+ `0` in the embed for that reason.
396
+ - **A transparent iframe is not click-through.** The element still takes pointer events over its whole
397
+ rectangle. If you want your own UI on top of the viewer, put it outside the frame.
398
+
399
+ ---
400
+
401
+ ## 10. Origins
402
+
403
+ There is no key and no allowlist. Anyone may embed the viewer.
404
+
405
+ **Inbound:** the frame acts on messages from its parent window and drops everything else, including
406
+ messages from sibling frames on the same page. This is a correctness check, not an authorisation one.
407
+
408
+ **Outbound:** the frame addresses its events to `*` until you send it something, then pins its replies
409
+ to the origin you spoke from. If you want replies pinned before you have sent anything - for a page
410
+ that configures the viewer entirely by URL and only listens - name your origin in the URL:
411
+
412
+ ```
413
+ ?origin=https://shop.example
414
+ ```
415
+
416
+ You should still check `event.source` and `event.origin` on your side. The snippet in §6 does.
417
+
418
+ ---
419
+
420
+ ## 11. What an embed cannot do
421
+
422
+ Honest list, so none of it is discovered late.
423
+
424
+ - **Custom loading and error UI inside the frame.** Our React component takes `loading` and `fallback`
425
+ slots; a React element cannot cross a `postMessage` boundary. Draw your own skeleton over the iframe
426
+ and remove it on `ready`.
427
+ - **Click-through.** See §9.
428
+ - **Server-side rendering.** The viewer is WebGL; there is no server-rendered fallback image.
429
+ - **Render to an image.** There is no `capture()` yet. If you need a PNG per item, that is a separate
430
+ service and is not built.
431
+ - **Resolve an inspect link on its own.** `?i=` carries the item's *configuration* — float, seed,
432
+ StatTrak, name plate, stickers, charm — but not which weapon, so a raw-iframe integration still has
433
+ to send `?weapon=`/`?paint=` (or `?hash=`) alongside it.
434
+ **This limit is the frame's, not the product's:** `@skinhub/viewer` takes an `inspectLink` prop and
435
+ handles the whole thing, because it decodes host-side and resolves the `defindex` against its own
436
+ weapon table before building the URL. If you are in React, you never see this.
437
+ - **Show a sticker, charm, agent or collectible on its own.** The embed renders a weapon or a glove
438
+ today. The rest of our catalogue has pages on our site but is not yet embeddable.
439
+ - **Style anything.** The frame is our document. `gizmocolor` and `gizmoshadow` are the only visual
440
+ hooks.
441
+ - **Two viewers cheaply.** Each frame is its own WebGL context. If you need a grid of them, drop
442
+ `scale` and turn `bloom` off, and expect a browser context limit somewhere around 8-16.
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # @skinhub/viewer
2
+
3
+ The SkinHub CS2 skin viewer as a React component — any weapon, knife or glove, at any wear and seed,
4
+ with stickers and a charm. The 3D runs in an iframe on our origin, so installing this pulls in no
5
+ `three` and no assets. React is the only peer dependency.
6
+
7
+ ```bash
8
+ bun add @skinhub/viewer @skinhub/cdn
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```tsx
14
+ 'use client'
15
+ import { SkinViewer } from '@skinhub/viewer'
16
+
17
+ export default function Page() {
18
+ return (
19
+ <SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 44, float: 0.27 }} style={{ width: 640, height: 420 }} />
20
+ )
21
+ }
22
+ ```
23
+
24
+ Or straight from a Steam inspect link — float, seed, StatTrak, name plate, stickers and charm all come
25
+ out of it:
26
+
27
+ ```tsx
28
+ <SkinViewer inspectLink={item.inspectLink} style={{ width: 640, height: 420 }} />
29
+ ```
30
+
31
+ Two things worth knowing up front:
32
+
33
+ - **Give it a size.** It fills its container and has no intrinsic size; at 0 px you see nothing.
34
+ - **No `key` derived from the item.** `key={item.weapon}` remounts the iframe on every change and turns
35
+ a cross-fade into a full document load. It already handles that itself.
36
+
37
+ ## Props
38
+
39
+ One of `item` or `inspectLink` is required. Everything else is optional.
40
+
41
+ | prop | type | default | |
42
+ |---|---|---|---|
43
+ | `item` | `{ weapon \| defindex, paintIndex, float?, seed?, statTrak?, nameTag?, legacyModel?, stickers?, charm? }` | — | the item. `paintIndex: 0` is vanilla |
44
+ | `inspectLink` | `string` | — | a masked Steam inspect link, instead of `item` |
45
+ | `origin` | `string` | `https://skinhub.gg` | where the embed is served. Read once, on mount |
46
+ | `view` | `'gun' \| 'hands' \| 'agent'` | `'gun'` | item alone, first-person, or held by an operator |
47
+ | `agent` | `{ id, pose? }` | default T | the operator, in `hands` and `agent` |
48
+ | `gloves` | `{ type, paintIndex, float?, seed? } \| null` | `null` | `null` is the wearer's own pair |
49
+ | `settings` | `{ camera?, quality?, environment?, overlays? }` | see below | shallow-merged per group |
50
+ | `interactions` | `{ orbit?, zoom?, dragStickers?, dragCharm? }` | orbit + zoom on | what the user may do |
51
+ | `editingSlot` | `number` | `-1` | which sticker slot is open; `5` is the charm |
52
+ | `onReady` | `() => void` | — | stopped loading — **a level, not an edge**: it fires again after every weapon or view change |
53
+ | `onError` | `(error) => void` | — | `no-item`, `bad-inspect-link`, `unknown-weapon`, `unreachable`, `render-failed`, `protocol-mismatch`, `bad-message` |
54
+ | `onChange` | `(item) => void` | — | the user dragged a sticker or the charm — **carries ids, not names**, see below |
55
+ | `onResize` | `({ width, height, dpr }) => void` | — | the canvas box changed |
56
+ | `onEditingSlotChange` | `(slot) => void` | — | |
57
+ | `loading` / `fallback` | `ReactNode` | — | your skeleton, and your empty state on error |
58
+ | `className` / `style` / `title` | | | on the wrapper |
59
+ | `handle` | `SkinViewerHandle` | — | from `useSkinViewer()` |
60
+
61
+ `settings.environment.background` defaults to `'transparent'`, so the canvas composites over your page.
62
+ `quality.bloomSpill` ships at `0` here. `MAP_NAMES` and `WEAPON_IDS` are exported as values for
63
+ building pickers.
64
+
65
+ Changing `float`, `seed`, `statTrak`, `nameTag`, `stickers` or `charm` updates **in place** — no
66
+ reload, no flicker. Changing the weapon, paint index, legacy variant or view shows a loading cover.
67
+
68
+ ### Copying the item back out
69
+
70
+ ```tsx
71
+ import { toInspectLink } from '@skinhub/viewer'
72
+
73
+ <button onClick={() => navigator.clipboard.writeText(toInspectLink(item))}>Copy inspect link</button>
74
+ ```
75
+
76
+ `fromInspectLink(link)` is the inverse, for seeding an editor from a link. `toPlacement(item)` gives
77
+ the decoded `@skinhub/cdn` placement if you want to write database rows instead.
78
+
79
+ ### `onChange` gives you ids, not names
80
+
81
+ The embed has never seen the sticker catalogue, so the item it hands back has `stickers: [{ id: 5032,
82
+ … }]` and no name or image. **`setItem(changed)` will therefore blank those out of your own UI** — a
83
+ silent data loss in your state, with nothing to catch. Merge it into what you already hold instead:
84
+
85
+ ```tsx
86
+ onChange={next =>
87
+ setItem(prev => ({
88
+ ...prev,
89
+ float: next.float,
90
+ seed: next.seed,
91
+ // keep your own row, take the viewer's placement
92
+ stickers: next.stickers.map(s => ({ ...myCatalogue[s.id], ...s })),
93
+ charm: next.charm && { ...prev.charm, ...next.charm },
94
+ }))
95
+ }
96
+ ```
97
+
98
+ ### Pointing at your own instance
99
+
100
+ `origin` defaults to `https://skinhub.gg`. Override it to run against a mirror, a proxy, or a local
101
+ instance while developing:
102
+
103
+ ```tsx
104
+ <SkinViewer item={item} origin="http://localhost:3000" />
105
+ ```
106
+
107
+ It is read once, at mount — it is the one prop that could only be applied by reloading the frame.
108
+
109
+ ## useSkinViewer()
110
+
111
+ ```tsx
112
+ const viewer = useSkinViewer()
113
+
114
+ <SkinViewer item={item} handle={viewer} />
115
+
116
+ viewer.status // 'connecting' | 'loading' | 'ready' | 'error'
117
+ viewer.error // SkinViewerError | null
118
+ viewer.problems // anything the embed rejected about the URL we built
119
+ viewer.reload()
120
+ ```
121
+
122
+ ## Troubleshooting
123
+
124
+ **A CORS error fetching the catalogue.** `cdn.skinhub.gg` does not send
125
+ `access-control-allow-origin`, so a **browser** `fetch` for `data/skins.json` is blocked by the
126
+ browser before your code sees it — it surfaces as a CORS failure rather than a status, so you cannot
127
+ even catch it. Call `@skinhub/cdn` from your **server** (a route handler, `getServerSideProps`, an RSC)
128
+ and pass the rows down. The files are 4–6 MB each, so you want them server-side regardless.
129
+
130
+ **Nothing renders.** If the container is 0 px in one dimension you get an empty box; the component
131
+ warns about that in development. Otherwise `onError` reports `unreachable` after 15 seconds, naming the
132
+ URL it could not reach — that means the embed never answered, so check the origin is reachable from
133
+ this browser and that your page's `Content-Security-Policy` allows framing it (`frame-src`).
134
+
135
+ **`onReady` fired but the picture is still covered.** `onReady` means "stopped loading" for the current
136
+ identity, and it fires again after every weapon or view change. Treat it as a level, not an edge.
137
+
138
+ **`protocol-mismatch`.** This package and the embed disagree about the wire. There is no back-compat
139
+ window by design: update `@skinhub/viewer`, or reload the embed if it is the stale one — the error
140
+ message says which.
141
+
142
+ ## More
143
+
144
+ - **[Quick-start app](https://github.com/SkinHubgg/skinhub-quick-start)** — a working skin picker built
145
+ on this and `@skinhub/cdn`, ending in an inspect link. The full example.
146
+ - **[EMBED.md](./EMBED.md)** — the raw `<iframe>` URL and `postMessage` contract, for stacks that are
147
+ not React. A PHP, Rails or plain-HTML page can interpolate an item into an `src` and be done; that
148
+ path is first-class, not a fallback.
149
+ - **[@skinhub/cdn](https://github.com/SkinHubgg/skinhub-cdn)** — the CS2 catalogue this pairs with:
150
+ skins, stickers, gloves, agents, and the inspect-link codec.
151
+ - [Development and releasing](./CONTRIBUTING.md) — how the wire is kept in step with the embed.
152
+
153
+ MIT
@@ -0,0 +1,18 @@
1
+ import type { SkinViewerProps } from './types.js';
2
+ /** Where the embed is served from. Overridable per component - see {@link SkinViewerProps.origin}. */
3
+ export declare const DEFAULT_ORIGIN = "https://skinhub.gg";
4
+ /**
5
+ * *** HOW LONG THE EMBED HAS TO ANNOUNCE ITSELF BEFORE WE CALL IT UNREACHABLE. ***
6
+ *
7
+ * The frame posts `hello` as soon as its script runs, so this is a document fetch plus a parse - a few
8
+ * hundred milliseconds on a warm connection. Fifteen seconds is therefore not a performance budget, it
9
+ * is the point past which "still loading" stops being a credible explanation for an empty box.
10
+ *
11
+ * *** DELIBERATELY GENEROUS, BECAUSE A FALSE POSITIVE HERE IS SELF-HEALING AND A FALSE NEGATIVE IS
12
+ * NOT. *** The iframe stays mounted under the `fallback`, so a slow connection that lands at sixteen
13
+ * seconds clears the error and renders. A timer too short would flash an error at people on bad
14
+ * networks; no timer at all leaves them with a blank rectangle and nothing to search for.
15
+ */
16
+ export declare const CONNECT_TIMEOUT_MS = 15000;
17
+ export declare const SkinViewer: (props: SkinViewerProps) => import("react").JSX.Element;
18
+ //# sourceMappingURL=SkinViewer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SkinViewer.d.ts","sourceRoot":"","sources":["../src/SkinViewer.tsx"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,YAAY,CAAA;AAEhF,sGAAsG;AACtG,eAAO,MAAM,cAAc,uBAAuB,CAAA;AA4ClD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,QAAS,CAAA;AAExC,eAAO,MAAM,UAAU,GAAI,OAAO,eAAe,gCAmWhD,CAAA"}