@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
@@ -0,0 +1,465 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * *** `<SkinViewer />` - THE EMBED AS A REACT COMPONENT. ***
5
+ *
6
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
7
+ * *** THE ONE PROPERTY THIS FILE EXISTS TO PROTECT: A CHEAP PROP MUST NOT RELOAD THE FRAME. ***
8
+ *
9
+ * Owner's requirement: `float`, `seed`, `statTrak`, `nameTag`, the stickers and the charm update in
10
+ * place *"to make it feel just like in our website"*; only the weapon, the paint kit and the view go
11
+ * behind a loading card. The renderer guarantees it for a prop change, and `/frame`'s
12
+ * identity-preserving merge guarantees it across the wire - measured there at 140 float messages, 137
13
+ * animation frames, 769 timer samples and ZERO covered frames.
14
+ *
15
+ * *** THIS COMPONENT CAN STILL THROW IT ALL AWAY IN THREE LINES, AND HERE THEY ARE, GUARDED: ***
16
+ *
17
+ * 1. THE `src` IS BUILT ONCE AND NEVER REWRITTEN. Assigning `src` reloads the document, which drops
18
+ * the GL context, re-downloads the model and re-runs every shader compile. Every prop change
19
+ * after mount is a `postMessage`, without exception. `boot` is state that only `reload()` writes.
20
+ *
21
+ * 2. THE `<iframe>` HAS A `key`, AND IT IS DELIBERATELY NOT DERIVED FROM ANY PROP. It is a counter
22
+ * that only `reload()` increments. *** A `key={item.weapon}` HERE - OR ON THIS COMPONENT, IN A
23
+ * CONSUMER'S TREE - WOULD REMOUNT THE FRAME ON EVERY WEAPON CHANGE *** and turn a two-second
24
+ * cross-fade into a full document load. The frame already covers itself on an identity change;
25
+ * it does not need help and cannot be helped this way. The same warning is written into
26
+ * `/frame`'s own file, because the mistake is available at both ends.
27
+ *
28
+ * 3. THE MESSAGE IS A DIFF. See `state.ts`: sending the whole state every tick would be correct on
29
+ * the wire and would still re-seed the renderer's sticker draft sixty times a second.
30
+ *
31
+ * ═════════════════════════════════════════════════════════════════════════════════════════════
32
+ * *** AND THE SECOND: A STALE PACKAGE FAILS LOUDLY, NEVER PARTIALLY. ***
33
+ *
34
+ * No back-compat, no version window - decided policy, and the README's *Versioning* section is its public statement. A protocol mismatch in
35
+ * either direction is terminal: the frame renders nothing, this component stops sending, `onError`
36
+ * carries the sentence naming which side is out of date, and `fallback` gets it too. Nothing is
37
+ * half-applied, because a subtly wrong picture is worse than a blank one with an explanation.
38
+ */
39
+
40
+ import { useCallback, useEffect, useRef, useState } from 'react'
41
+
42
+ import { hostMessage, readFrameEvent, type FrameEvent } from './protocol.js'
43
+ import { toPublicItem } from './item.js'
44
+ import { coversCanvas, diffState, frameUrl, resolveState, type DesiredState } from './state.js'
45
+ import { LINK, type ViewerLink } from './link.js'
46
+ import type { SkinViewerError, SkinViewerProps, ViewerStatus } from './types.js'
47
+
48
+ /** Where the embed is served from. Overridable per component - see {@link SkinViewerProps.origin}. */
49
+ export const DEFAULT_ORIGIN = 'https://skinhub.gg'
50
+
51
+ /**
52
+ * *** THE TWO DEVELOPMENT-ONLY WARNINGS BELOW ARE GUARDED BY THE LITERAL `process.env.NODE_ENV`
53
+ * EXPRESSION, WRITTEN OUT IN FULL AT EACH SITE, AND IT HAS TO STAY THAT WAY. ***
54
+ *
55
+ * Every bundler - webpack, Vite, Next, esbuild, Rollup - substitutes that exact member expression and
56
+ * nothing else. *** ANY INDIRECTION AT ALL DEFEATS IT: *** a helper function, a hoisted `const`, or -
57
+ * measured, and the reason this note exists - optional chaining. `process?.env?.NODE_ENV` is a
58
+ * different AST node, no bundler substitutes it, so it stays a live runtime lookup and both warnings
59
+ * plus their message strings ship inside every customer's production bundle. Written plainly it folds
60
+ * to a constant at build time and the minifier deletes the blocks outright.
61
+ *
62
+ * `test/bundle.test.ts` asserts the literal survives into a real bundle of `dist/`, which is exactly
63
+ * what caught the optional-chained version of this.
64
+ *
65
+ * *** WHICH IS ALSO WHY THERE IS NO `typeof process` GUARD. *** It would let an unbundled browser
66
+ * import this without a `ReferenceError`, and it would cost the substitution to do it. There is no
67
+ * such consumer: this is a React component, React's own development build reads the same bare global,
68
+ * and nobody reaches either without a bundler.
69
+ *
70
+ * `process` IS DECLARED HERE, MODULE-SCOPED, rather than pulled in from `@types/node`.
71
+ * `tsconfig.build.json` compiles with `"types": []` precisely so the published `.d.ts` cannot oblige a
72
+ * browser consumer to install Node's globals in order to typecheck, and a bare `process.env` is a
73
+ * compile error under it. A `declare const` INSIDE a module shadows the ambient one rather than
74
+ * colliding with it, so `bun run typecheck` - which does load bun's globals - and the build agree.
75
+ */
76
+ declare const process: { env: { NODE_ENV?: string } }
77
+
78
+ /**
79
+ * *** THE TWO KINDS OF FAILURE, AND THEY ARE HELD DIFFERENTLY ON PURPOSE. ***
80
+ *
81
+ * A FRAME FAILURE (`protocol-mismatch`, `render-failed`) arrives as an event and is STICKY state: the
82
+ * scene is gone and nothing in the props can bring it back, so it stays until a reload or - for a lost
83
+ * render - until the next identity change gives the frame something new to try.
84
+ *
85
+ * A SUBJECT FAILURE (`no-item`, `bad-inspect-link`, `unknown-weapon`) is DERIVED FROM THE PROPS and is
86
+ * never sticky. It is a fact about this render's arguments, so the moment the arguments are good it is
87
+ * over. That distinction is the difference between a viewer that recovers when a query resolves and
88
+ * one an integrator has to remount.
89
+ */
90
+ const isFatal = (code: SkinViewerError['code']) =>
91
+ code === 'protocol-mismatch' || code === 'render-failed' || code === 'unreachable'
92
+
93
+ /**
94
+ * *** HOW LONG THE EMBED HAS TO ANNOUNCE ITSELF BEFORE WE CALL IT UNREACHABLE. ***
95
+ *
96
+ * The frame posts `hello` as soon as its script runs, so this is a document fetch plus a parse - a few
97
+ * hundred milliseconds on a warm connection. Fifteen seconds is therefore not a performance budget, it
98
+ * is the point past which "still loading" stops being a credible explanation for an empty box.
99
+ *
100
+ * *** DELIBERATELY GENEROUS, BECAUSE A FALSE POSITIVE HERE IS SELF-HEALING AND A FALSE NEGATIVE IS
101
+ * NOT. *** The iframe stays mounted under the `fallback`, so a slow connection that lands at sixteen
102
+ * seconds clears the error and renders. A timer too short would flash an error at people on bad
103
+ * networks; no timer at all leaves them with a blank rectangle and nothing to search for.
104
+ */
105
+ export const CONNECT_TIMEOUT_MS = 15_000
106
+
107
+ export const SkinViewer = (props: SkinViewerProps) => {
108
+ const { className, style, title = 'SkinHub viewer', loading, fallback, handle } = props
109
+
110
+ /*
111
+ * RESOLVED ON EVERY RENDER, NOT MEMOISED, AND THAT IS THE CHEAPER OPTION HERE.
112
+ *
113
+ * A memo would need a dependency array over props a consumer writes INLINE - `item={{…}}`,
114
+ * `settings={{…}}` - so its identity changes every render anyway and the memo would only add a
115
+ * comparison to the work it fails to skip. Everything downstream compares by VALUE for the same
116
+ * reason (see `diffState`), so a fresh object here costs nothing: it produces no patch, no message
117
+ * and no render in the frame.
118
+ */
119
+ const desired = resolveState(props)
120
+ const desiredRef = useRef(desired)
121
+ desiredRef.current = desired
122
+
123
+ /*
124
+ * THE ORIGIN IS READ ONCE. See the prop's own doc: it is the single value that could only be applied
125
+ * by reloading the frame, and a prop that quietly throws away the GL context is the thing this
126
+ * component is built not to have.
127
+ */
128
+ const originRef = useRef(props.origin ?? DEFAULT_ORIGIN)
129
+
130
+ /**
131
+ * *** THE `src`, AND THE STATE THAT `src` EXPRESSED. ***
132
+ *
133
+ * Built in a `useState` INITIALISER rather than an effect, so the first frame anyone sees is the
134
+ * integrator's item and not ours corrected a tick later - and so a server render emits the same
135
+ * attribute the browser will, with no hydration mismatch to paper over.
136
+ *
137
+ * `nonce` is the `<iframe>`'s key and only `reload()` moves it. See point 2 in the header.
138
+ */
139
+ const [boot, setBoot] = useState(() => {
140
+ const { src, expressed } = frameUrl(originRef.current, desired)
141
+ return { src, expressed, nonce: 0 }
142
+ })
143
+
144
+ /** What the frame has been told so far. The baseline every later diff is measured against. */
145
+ const sent = useRef<DesiredState>(boot.expressed)
146
+ /** No `set` may be sent before the frame has announced itself; until then it has no listener. */
147
+ const connected = useRef(false)
148
+ const frame = useRef<HTMLIFrameElement>(null)
149
+ /** Cancelled the moment the frame speaks; see {@link CONNECT_TIMEOUT_MS}. */
150
+ const connectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
151
+
152
+ const [status, setStatus] = useState<ViewerStatus>('connecting')
153
+ const [error, setError] = useState<SkinViewerError | null>(null)
154
+ const [problems, setProblems] = useState<readonly string[]>([])
155
+
156
+ /*
157
+ * THE CALLBACKS, THROUGH A REF.
158
+ *
159
+ * `onChange` fires on every pointer move of a sticker drag. Putting the handler itself in a
160
+ * dependency array would re-attach the window listener on every render of a consumer who writes
161
+ * `onChange={item => setItem(item)}` inline - which is all of them. One ref, updated during render,
162
+ * and the listener effect below has no dependencies at all.
163
+ */
164
+ const handlers = useRef(props)
165
+ handlers.current = props
166
+
167
+ const post = useCallback((message: unknown) => {
168
+ const target = frame.current?.contentWindow
169
+ if (!target) return
170
+ // TARGETED AT THE FRAME'S ORIGIN AND NEVER `'*'`. We know it - we built the URL - so there is no
171
+ // reason to broadcast a customer's item state to whatever document happens to be there.
172
+ target.postMessage(message, originRef.current)
173
+ }, [])
174
+
175
+ const report = useCallback((next: SkinViewerError) => {
176
+ handlers.current.onError?.(next)
177
+ if (!isFatal(next.code)) return
178
+ setError(next)
179
+ setStatus('error')
180
+ // A MISMATCH IS ONE-WAY. There is no path back: a frame that has answered one message this
181
+ // package cannot read is a frame whose next answer it also cannot trust.
182
+ if (next.code === 'protocol-mismatch') connected.current = false
183
+ }, [])
184
+
185
+ /**
186
+ * *** THE FLUSH. Every prop change in this package leaves through this function. ***
187
+ *
188
+ * Called after every render and again the moment the frame connects, because a prop can change
189
+ * before the iframe's document exists and that update must not be lost - it has to wait, not
190
+ * evaporate.
191
+ */
192
+ const flush = useCallback(() => {
193
+ if (!connected.current) return
194
+ const next = desiredRef.current
195
+ const patch = diffState(sent.current, next)
196
+ if (!patch) return
197
+
198
+ /*
199
+ * THE BASELINE KEEPS THE LAST ITEM WE ACTUALLY SENT. A render whose `item` was null - a host
200
+ * whose query briefly returned `undefined` - must not record "no item" as the thing the frame is
201
+ * showing, or the next real item would be diffed against a hole and sent in full for no reason.
202
+ */
203
+ sent.current = { ...next, item: next.item ?? sent.current.item }
204
+ if (coversCanvas(patch, next.view)) {
205
+ setStatus('loading')
206
+ // A NEW ITEM IS A NEW CHANCE. A lost GL context or a 404 on one model says nothing about the
207
+ // next one, so the fallback comes down and the frame is allowed to try. A protocol mismatch is
208
+ // not cleared here: that one is about the conversation, not the item.
209
+ setError(current => (current?.code === 'render-failed' ? null : current))
210
+ }
211
+ post(hostMessage(patch))
212
+ }, [post])
213
+
214
+ useEffect(flush)
215
+
216
+ /* ── THE CONNECTION DEADLINE ──────────────────────────────────────────────────────────────── */
217
+ /**
218
+ * *** THE ONLY THING STANDING BETWEEN AN UNREACHABLE ORIGIN AND A SILENT EMPTY BOX. ***
219
+ *
220
+ * Keyed on `boot.nonce`, so `reload()` genuinely retries rather than inheriting a spent deadline.
221
+ * See {@link CONNECT_TIMEOUT_MS} and the `unreachable` code for why the browser gives us nothing
222
+ * else to go on.
223
+ */
224
+ useEffect(() => {
225
+ connectTimer.current = setTimeout(() => {
226
+ connectTimer.current = null
227
+ if (connected.current) return
228
+ report({
229
+ code: 'unreachable',
230
+ message: `The SkinHub viewer embed at ${boot.src} did not respond within ${Math.round(
231
+ CONNECT_TIMEOUT_MS / 1000,
232
+ )}s. Nothing has rendered. Check that the origin is reachable from this browser, that it serves /frame, and that your page's Content-Security-Policy allows framing it (frame-src).`,
233
+ })
234
+ }, CONNECT_TIMEOUT_MS)
235
+
236
+ return () => {
237
+ if (connectTimer.current !== null) clearTimeout(connectTimer.current)
238
+ connectTimer.current = null
239
+ }
240
+ }, [boot.nonce, boot.src, report])
241
+
242
+ /* ── THE CHANNEL ──────────────────────────────────────────────────────────────────────────── */
243
+ useEffect(() => {
244
+ const onMessage = (event: MessageEvent) => {
245
+ /*
246
+ * BOTH CHECKS, AND THEY ARE NOT THE SAME CHECK. `event.source` is the sending WINDOW and
247
+ * cannot be spoofed, so it is what stops a sibling frame or an ad tag on the host page from
248
+ * driving this component. `event.origin` is what stops a frame that has been navigated
249
+ * somewhere else from continuing to talk to us. `EMBED.md` §10 tells hand-rolled hosts to do
250
+ * exactly this, and a package that told them to and did not would be worth nothing.
251
+ */
252
+ if (event.source !== frame.current?.contentWindow) return
253
+ if (event.origin !== originRef.current) return
254
+
255
+ const reading = readFrameEvent(event.data)
256
+ if (reading.kind === 'ignore') return
257
+ if (reading.kind === 'mismatch') {
258
+ report(reading.error)
259
+ return
260
+ }
261
+ receive(reading.event)
262
+ }
263
+
264
+ /* Shadowing the `handle` PROP here would be a trap, so the reader is named for what it does. */
265
+ const receive = (event: FrameEvent) => {
266
+ switch (event.type) {
267
+ case 'hello': {
268
+ connected.current = true
269
+ if (connectTimer.current !== null) {
270
+ clearTimeout(connectTimer.current)
271
+ connectTimer.current = null
272
+ }
273
+ /*
274
+ * *** A LATE ARRIVAL UNDOES `unreachable`, AND ONLY THAT ONE. *** The iframe is never
275
+ * unmounted - the `fallback` is drawn OVER it - so a connection that lands after the timer
276
+ * is a working viewer sitting under an error card, which is worse than the blank box the
277
+ * timer was added to prevent. Cleared here rather than in the timer because this is the
278
+ * only place we learn the embed is really there.
279
+ */
280
+ setError(current => (current?.code === 'unreachable' ? null : current))
281
+ setStatus(current => (current === 'connecting' || current === 'error' ? 'loading' : current))
282
+ setProblems(event.problems)
283
+ /*
284
+ * A PROBLEM IN `hello` IS OUR BUG AND NOT THE INTEGRATOR'S. They passed props; this
285
+ * package turned them into a query string; the frame is naming the part of that string it
286
+ * could not read. It is surfaced on the hook and warned about in development rather than
287
+ * routed to `onError`, which is for failures they can act on.
288
+ */
289
+ if (event.problems.length > 0 && process.env.NODE_ENV !== 'production')
290
+ console.warn('[@skinhub/viewer] the embed rejected part of the URL this package built:', event.problems)
291
+ // Anything that changed while the document was still loading goes now.
292
+ flush()
293
+ return
294
+ }
295
+ case 'ready':
296
+ // A LEVEL, NOT AN EDGE - it fires again after every identity change. And it fires after a
297
+ // FAILED load too, which is why a fatal error is not cleared here.
298
+ setStatus(current => (current === 'error' ? current : 'ready'))
299
+ handlers.current.onReady?.()
300
+ return
301
+ case 'error':
302
+ report(event.error)
303
+ return
304
+ case 'change': {
305
+ /*
306
+ * *** THE USER'S EDIT, AND IT IS ALSO WRITTEN INTO OUR BASELINE. ***
307
+ *
308
+ * The second half is the one that is easy to miss: without it, the next patch we send
309
+ * would be diffed against an item that never learned about the drag, so the very next
310
+ * float tick would restate the OLD sticker offsets and yank the sticker back under the
311
+ * user's cursor. The frame is the authority on what the user did; we follow it.
312
+ */
313
+ sent.current = { ...sent.current, item: event.item }
314
+ handlers.current.onChange?.(toPublicItem(event.item))
315
+ return
316
+ }
317
+ case 'editing-slot':
318
+ handlers.current.onEditingSlotChange?.(event.slot)
319
+ return
320
+ case 'resize':
321
+ handlers.current.onResize?.({ width: event.width, height: event.height, dpr: event.dpr })
322
+ return
323
+ }
324
+ }
325
+
326
+ window.addEventListener('message', onMessage)
327
+ return () => window.removeEventListener('message', onMessage)
328
+ }, [flush, report])
329
+
330
+ /* ── THE SUBJECT'S OWN FAILURES ───────────────────────────────────────────────────────────── */
331
+ /*
332
+ * `no-item`, `bad-inspect-link` and `unknown-weapon` happen HERE rather than in the frame - they are
333
+ * decided while resolving props, before a message is sent. Reported once per distinct message, not
334
+ * once per render, because a host re-rendering at 60 Hz with a bad link would otherwise fill their
335
+ * console and their error reporter.
336
+ */
337
+ const reported = useRef<string | null>(null)
338
+ useEffect(() => {
339
+ const subjectError = desiredRef.current.subjectError
340
+ if (!subjectError) {
341
+ reported.current = null
342
+ return
343
+ }
344
+ if (reported.current === subjectError.message) return
345
+ reported.current = subjectError.message
346
+ handlers.current.onError?.(subjectError)
347
+ })
348
+
349
+ /* ── THE HANDLE ───────────────────────────────────────────────────────────────────────────── */
350
+ const link: ViewerLink | undefined = handle?.[LINK]
351
+ useEffect(() => {
352
+ if (!link) return
353
+ link.reload = () => {
354
+ /*
355
+ * REBUILT FROM WHAT IS ON SCREEN NOW, not from the props this component mounted with - a reload
356
+ * that reverted the item to its first value would be a surprise, not a refresh.
357
+ *
358
+ * AND IT FALLS BACK TO THE LAST ITEM WE SENT, for the case where this render happens to have no
359
+ * item: a `reload()` fired from a button while the host's query is momentarily `undefined` must
360
+ * bring the item back, not the instruction card.
361
+ */
362
+ const current = desiredRef.current
363
+ const next: DesiredState = current.item
364
+ ? current
365
+ : { ...current, item: sent.current.item, help: sent.current.item ? null : current.help }
366
+ const { src, expressed } = frameUrl(originRef.current, next)
367
+ sent.current = expressed
368
+ connected.current = false
369
+ setStatus('connecting')
370
+ setError(null)
371
+ setProblems([])
372
+ setBoot(previous => ({ src, expressed, nonce: previous.nonce + 1 }))
373
+ }
374
+ return () => {
375
+ link.reload = () => {}
376
+ }
377
+ }, [link])
378
+
379
+ /*
380
+ * WHAT THE HOOK IS TOLD IS THE RESOLVED VIEW OF BOTH FAILURE KINDS, not the raw event state - so a
381
+ * consumer rendering `viewer.status` sees `'error'` for a link that did not decode, without this
382
+ * component having to make a prop-derived fact sticky to get it there.
383
+ */
384
+ const publicError = error ?? desired.subjectError
385
+ const publicStatus = publicError ? 'error' : status
386
+ useEffect(() => {
387
+ link?.publish({ status: publicStatus, error: publicError, problems })
388
+ }, [link, publicStatus, publicError, problems])
389
+
390
+ /* ── THE BOX ──────────────────────────────────────────────────────────────────────────────── */
391
+ const box = useRef<HTMLDivElement>(null)
392
+ useEffect(() => {
393
+ if (process.env.NODE_ENV === 'production') return
394
+ const element = box.current
395
+ if (!element || (element.offsetWidth > 0 && element.offsetHeight > 0)) return
396
+ console.warn(
397
+ '[@skinhub/viewer] <SkinViewer> measured 0 px in one dimension, so nothing will be visible. ' +
398
+ 'The viewer fills its container and has no intrinsic size - give it one with `style` or `className` ' +
399
+ '(e.g. style={{ width: 640, height: 420 }}), or size the element you put it in.',
400
+ )
401
+ }, [])
402
+
403
+ /*
404
+ * ── WHAT IS DRAWN OVER THE FRAME, IN ORDER ────────────────────────────────────────────────
405
+ *
406
+ * 1. A FATAL FAILURE takes the `fallback` slot. The scene is gone underneath it.
407
+ *
408
+ * 2. A SUBJECT FAILURE takes it too WHEN THE CALLER PROVIDED ONE - they said what they want shown
409
+ * for a bad item and that beats anything we would draw. *** WITH NO `fallback` IT DRAWS NOTHING,
410
+ * AND THAT IS THE POINT: *** the frame is showing the instruction card, and covering it with a
411
+ * skeleton would replace the one screen in this product whose whole job is to teach.
412
+ *
413
+ * 3. OTHERWISE `loading`, until the frame says `ready`.
414
+ */
415
+ const overlay = error
416
+ ? renderFallback(fallback, error)
417
+ : desired.subjectError
418
+ ? (renderFallback(fallback, desired.subjectError) ?? null)
419
+ : status === 'ready'
420
+ ? null
421
+ : loading
422
+
423
+ return (
424
+ <div ref={box} className={className} style={{ position: 'relative', ...style }}>
425
+ <iframe
426
+ /* Point 2 in the header. Nothing but `reload()` may move this. */
427
+ key={boot.nonce}
428
+ ref={frame}
429
+ src={boot.src}
430
+ title={title}
431
+ /*
432
+ * *** NO `referrerPolicy`, AND THAT IS A DECISION. *** The embed reads the framing origin
433
+ * from this request's `Referer` header. Setting `no-referrer` here - which looks like good
434
+ * hygiene - would make every embed anonymous to us, which is not a privacy win for the host
435
+ * (we already know the URL they asked for) and does break the one thing that header decides.
436
+ */
437
+ style={{
438
+ position: 'absolute',
439
+ inset: 0,
440
+ width: '100%',
441
+ height: '100%',
442
+ border: 0,
443
+ // The frame paints nothing of its own, so the canvas composites over the host's page.
444
+ // Without this a browser's default white iframe background would sit in between.
445
+ background: 'transparent',
446
+ display: 'block',
447
+ }}
448
+ />
449
+ {overlay === null || overlay === undefined ? null : (
450
+ /*
451
+ * DRAWN OVER THE FRAME, NEVER INSIDE IT - a React element cannot be structured-cloned across
452
+ * a `postMessage` boundary, so this is the only place a consumer's node can live.
453
+ *
454
+ * `pointerEvents: 'none'` so a spinner does not eat the orbit drag underneath it. A consumer
455
+ * whose overlay is interactive turns it back on in their own node, which is the right way
456
+ * round: the common case costs them nothing and the rare one is one line.
457
+ */
458
+ <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>{overlay}</div>
459
+ )}
460
+ </div>
461
+ )
462
+ }
463
+
464
+ const renderFallback = (fallback: SkinViewerProps['fallback'], error: SkinViewerError) =>
465
+ typeof fallback === 'function' ? fallback(error) : fallback
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * *** `@skinhub/viewer` - THE SKINHUB CS2 SKIN VIEWER, AS A REACT COMPONENT. ***
3
+ *
4
+ * import { SkinViewer } from '@skinhub/viewer'
5
+ *
6
+ * <SkinViewer item={{ weapon: 'weapon_ak47', paintIndex: 1449, float: 0.27 }} style={{ height: 420 }} />
7
+ * <SkinViewer inspectLink={tradeOffer.inspectLink} style={{ height: 420 }} />
8
+ *
9
+ * The 3D is not in here. It is a page on our origin that this component embeds and drives over
10
+ * `postMessage`, which is why installing this pulls in no `three`, no `@react-three/fiber` and no
11
+ * asset bundle - the only peer dependency is React. `EMBED.md` documents the same contract for stacks
12
+ * that are not React; this package is the React-shaped door onto it, not a second implementation.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
15
+ * WHAT IS EXPORTED, AND WHY THE LIST IS SHORT.
16
+ *
17
+ * `SkinViewer`, `useSkinViewer` - the component and its verbs.
18
+ * the types - so a consumer can name a prop's shape in their own code.
19
+ * `MAP_NAMES`, `WEAPON_IDS` - the two closed vocabularies, as values, for building a picker.
20
+ * the defindex table - because an integrator with a Steam inventory has numbers.
21
+ *
22
+ * Nothing else FROM HERE. The URL builder and the diff are implementation: if they were public, a
23
+ * customer could build a state this package would then have to keep working, and the whole point of
24
+ * the version integer is that there is exactly one shape to keep working.
25
+ *
26
+ * *** THE WIRE TYPES ARE THE ONE EXCEPTION AND THEY ARE A SEPARATE DOOR - `@skinhub/viewer/protocol`.
27
+ * *** Deliberately not the barrel, so they never appear in an autocomplete next to `SkinViewer` and
28
+ * nobody reaches for them by accident. Exported at all because `EMBED.md` §6 already publishes that
29
+ * exact shape in prose, for hosts that are not React, and because the app repo's conformance test has
30
+ * to import both halves of the wire to prove they still agree. See `protocol.ts` for both.
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────────────────────────
33
+ * *** DO NOT ADD `"sideEffects": false` TO `package.json`. Measured, Bun 1.3.13: *** with that flag
34
+ * set, a `bun build` bundle of this barrel tree-shakes the entire package away and emits a 248-byte
35
+ * file that re-exports fourteen names and defines none of them. It builds, it publishes, and it fails
36
+ * at import time in the consumer. The flag is the correct thing to want and it is not worth this.
37
+ *
38
+ * (The package's OWN build is `tsc`, not a bundler - one emitted file per source file, matching
39
+ * `@skinhub/cdn` - so the flag would only ever bite a consumer's bundler. That is worse, not better:
40
+ * it moves the failure to somebody else's build.)
41
+ */
42
+
43
+ export { SkinViewer, DEFAULT_ORIGIN } from './SkinViewer.js'
44
+ export { useSkinViewer } from './useSkinViewer.js'
45
+
46
+ /**
47
+ * *** ITEM OUT, LINK BACK. *** A picker is not finished when it can show the item - it is finished
48
+ * when it can hand you the link. See `item.ts` for the three easy-to-get-wrong facts these encode.
49
+ */
50
+ export { fromInspectLink, toInspectLink, toPlacement } from './item.js'
51
+
52
+ export type {
53
+ MapName,
54
+ SkinViewerCharm,
55
+ SkinViewerError,
56
+ SkinViewerErrorCode,
57
+ SkinViewerHandle,
58
+ SkinViewerItem,
59
+ SkinViewerProps,
60
+ SkinViewerSticker,
61
+ TimeOfDay,
62
+ ViewerAgent,
63
+ ViewerBackground,
64
+ ViewerCameraSettings,
65
+ ViewerEnvironmentSettings,
66
+ ViewerGloves,
67
+ ViewerInteractions,
68
+ ViewerOverlaySettings,
69
+ ViewerQualitySettings,
70
+ ViewerResize,
71
+ ViewerSettings,
72
+ ViewerStatus,
73
+ ViewerSubject,
74
+ ViewerView,
75
+ } from './types.js'
76
+ export { CHEAP_FIELDS, MAP_NAMES } from './types.js'
77
+
78
+ export type { KnownWeaponId, WeaponId } from './weapons.js'
79
+ export {
80
+ defindexForWeaponId,
81
+ isGloveId,
82
+ isKnownWeaponId,
83
+ normalizeWeaponId,
84
+ WEAPON_ID_ALIASES,
85
+ WEAPON_ID_BY_DEFINDEX,
86
+ WEAPON_IDS,
87
+ weaponIdForDefindex,
88
+ } from './weapons.js'