From c617f746cda575f94b80d53532ad8cb708859f3b Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 14:28:02 -0400 Subject: [PATCH 01/17] feat(inventory): add placeholder "View in 3D" button beside List on CSFloat First step toward embedding the SkinCraft 3D viewer in the extension (mirroring the phoenix partner-iframe integration). Adds a "View in 3D" button to the right of "List on CSFloat" in the inventory selected-item pane, laid out side-by-side via a new .market-btn-row. Styled after SkinCraft's classic-theme primary button (the viewer's top-island "Inspect" action): brand indigo fill with the white logo mark so it stands out beside the muted List button. Shares the List gating via a new canListOnCSFloat getter; the click handler is a no-op stub until the persistent-iframe embed is wired up. --- .../inventory/selected_item_info.ts | 85 ++++++++++++++++--- 1 file changed, 75 insertions(+), 10 deletions(-) diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index cbb6a3e3..1d9530a9 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -48,6 +48,13 @@ export class SelectedItemInfo extends FloatElement { margin-bottom: 10px; } + .market-btn-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + } + .market-btn-container { margin: 10px 0 10px 0; padding: 5px; @@ -64,6 +71,43 @@ export class SelectedItemInfo extends FloatElement { color: #ebebeb; text-decoration: none; } + + /* SkinCraft classic-theme primary button (mirrors the viewer's + top-island "Inspect" button so it stands out beside List). */ + .view-3d-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; + color: #f5f8ff; + background-color: #5155eb; + border-radius: 8px; + cursor: pointer; + text-decoration: none; + transition: all 180ms ease; + box-shadow: + 0 4px 12px hsl(0 0% 0% / 0.22), + 0 1px 2px hsl(0 0% 0% / 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.22); + } + + /* icon.svg is the colored brand mark; render it solid white so it + reads on the primary fill. */ + .view-3d-btn img { + filter: brightness(0) invert(1); + } + + .view-3d-btn:hover { + filter: brightness(1.1); + } + + .view-3d-btn:active { + transform: scale(0.98); + filter: brightness(0.95); + } `, ]; @@ -152,7 +196,10 @@ export class SelectedItemInfo extends FloatElement { } if (isSellableOnCSFloat(this.asset.description)) { - containerChildren.push(html`${this.renderListOnCSFloat()} ${this.renderFloatMarketListing()}`); + containerChildren.push( + html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
+ ${this.renderFloatMarketListing()}` + ); } if (containerChildren.length === 0) { @@ -216,19 +263,33 @@ export class SelectedItemInfo extends FloatElement { `; } - renderListOnCSFloat(): TemplateResult<1> { - if (this.stallListing) { - // Don't tell them to list it if it's already listed... - return html``; - } + /** Whether the signed-in user can list this exact item — gates both the + * "List on CSFloat" action and the "View in 3D" button beside it. */ + private get canListOnCSFloat(): boolean { + return ( + // Not already listed, owned by the signed-in user, and tradable. + !this.stallListing && + g_ActiveInventory?.m_owner?.strSteamId === g_steamID && + !!this.asset?.description?.tradable + ); + } - if (g_ActiveInventory?.m_owner?.strSteamId !== g_steamID) { - // Not the signed-in user, don't show + renderViewIn3D(): TemplateResult<1> { + if (!this.canListOnCSFloat) { return html``; } - if (!this.asset?.description?.tradable) { - // Don't show if item isn't tradable + // Placeholder: opens the SkinCraft 3D embed in a follow-up. + return html` + + + View in 3D + + `; + } + + renderListOnCSFloat(): TemplateResult<1> { + if (!this.canListOnCSFloat) { return html``; } @@ -249,6 +310,10 @@ export class SelectedItemInfo extends FloatElement { `; } + private handleViewIn3D() { + // No-op for now — SkinCraft 3D embed wiring lands in a follow-up. + } + async processSelectChange() { // Reset state in-case they swap between skin and non-skin this.itemInfo = undefined; From b4fac119038031f251407d98b9b1547d71ce6279 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 15:34:26 -0400 Subject: [PATCH 02/17] feat(inventory): embed persistent SkinCraft viewer --- src/environment.dev.ts | 1 + src/environment.staging.ts | 1 + src/environment.ts | 1 + .../inventory/selected_item_info.ts | 37 +- .../inventory/skincraft_viewer_modal.ts | 418 ++++++++++++++++++ src/lib/services/skincraft_embed.ts | 233 ++++++++++ .../services/skincraft_embed_protocol.test.ts | 39 ++ src/lib/services/skincraft_embed_protocol.ts | 59 +++ 8 files changed, 775 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/inventory/skincraft_viewer_modal.ts create mode 100644 src/lib/services/skincraft_embed.ts create mode 100644 src/lib/services/skincraft_embed_protocol.test.ts create mode 100644 src/lib/services/skincraft_embed_protocol.ts diff --git a/src/environment.dev.ts b/src/environment.dev.ts index feed5bff..c001bb7e 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'http://localhost:3434/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://192.168.8.131:3000', }; diff --git a/src/environment.staging.ts b/src/environment.staging.ts index a5fd5eef..900749cf 100644 --- a/src/environment.staging.ts +++ b/src/environment.staging.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'https://reverse.watch/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://skincraft.gg', }; diff --git a/src/environment.ts b/src/environment.ts index 8879f056..0ef408c8 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'https://reverse.watch/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://skincraft.gg', }; diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index 1d9530a9..0680ea04 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -23,6 +23,7 @@ import {Contract} from '../../types/float_market'; import '../common/ui/floatbar'; import {ClientSend} from '../../bridge/client'; import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem'; +import {gSkinCraftEmbed} from '../../services/skincraft_embed'; import './list_item_modal'; /** @@ -72,8 +73,6 @@ export class SelectedItemInfo extends FloatElement { text-decoration: none; } - /* SkinCraft classic-theme primary button (mirrors the viewer's - top-island "Inspect" button so it stands out beside List). */ .view-3d-btn { display: inline-flex; align-items: center; @@ -84,8 +83,10 @@ export class SelectedItemInfo extends FloatElement { font-weight: 600; color: #f5f8ff; background-color: #5155eb; + border: 0; border-radius: 8px; cursor: pointer; + font-family: inherit; text-decoration: none; transition: all 180ms ease; box-shadow: @@ -94,8 +95,6 @@ export class SelectedItemInfo extends FloatElement { inset 0 1px 0 rgba(255, 255, 255, 0.22); } - /* icon.svg is the colored brand mark; render it solid white so it - reads on the primary fill. */ .view-3d-btn img { filter: brightness(0) invert(1); } @@ -263,28 +262,31 @@ export class SelectedItemInfo extends FloatElement { `; } - /** Whether the signed-in user can list this exact item — gates both the - * "List on CSFloat" action and the "View in 3D" button beside it. */ private get canListOnCSFloat(): boolean { return ( - // Not already listed, owned by the signed-in user, and tradable. !this.stallListing && g_ActiveInventory?.m_owner?.strSteamId === g_steamID && !!this.asset?.description?.tradable ); } + private get skinCraftInspect(): string | undefined { + return ( + this.asset?.asset_properties?.find((property) => property.propertyid === 6)?.string_value?.trim() || + undefined + ); + } + renderViewIn3D(): TemplateResult<1> { - if (!this.canListOnCSFloat) { + if (!this.canListOnCSFloat || !this.asset || !isSkin(this.asset.description) || !this.skinCraftInspect) { return html``; } - // Placeholder: opens the SkinCraft 3D embed in a follow-up. return html` - - + `; } @@ -310,8 +312,15 @@ export class SelectedItemInfo extends FloatElement { `; } - private handleViewIn3D() { - // No-op for now — SkinCraft 3D embed wiring lands in a follow-up. + private handleViewIn3D(): void { + if (!this.asset || !this.skinCraftInspect) return; + + const icon = this.asset.description.icon_url_large || this.asset.description.icon_url; + gSkinCraftEmbed.open({ + inspect: this.skinCraftInspect, + name: this.asset.description.market_hash_name, + iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, + }); } async processSelectChange() { diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts new file mode 100644 index 00000000..664b6991 --- /dev/null +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -0,0 +1,418 @@ +export type SkinCraftViewerTarget = { + inspect: string; + name: string; + iconUrl?: string; + itemUrl: string; +}; + +const MODAL_STYLES = ` + :host { + color: #f5f8ff; + font-family: Arial, Helvetica, sans-serif; + } + + dialog { + width: min(1120px, calc(100vw - 48px)); + max-width: none; + padding: 0; + border: 1px solid rgba(193, 206, 255, 0.12); + border-radius: 12px; + overflow: hidden; + color: inherit; + background: #15171c; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); + } + + dialog::backdrop { + background: rgba(5, 7, 12, 0.78); + backdrop-filter: blur(6px); + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 52px; + padding: 0 12px 0 18px; + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + background: #1b1e25; + } + + .modal-title { + min-width: 0; + overflow: hidden; + font-size: 15px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .modal-brand { + color: rgba(245, 248, 255, 0.58); + font-weight: 400; + } + + .close-button { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + color: rgba(245, 248, 255, 0.72); + font: inherit; + font-size: 22px; + line-height: 1; + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease; + } + + .close-button:hover { + color: #fff; + background: rgba(255, 255, 255, 0.09); + } + + .viewer-stage { + position: relative; + aspect-ratio: 16 / 9; + max-height: calc(100vh - 104px); + background: #111318; + } + + iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + opacity: 0; + transition: opacity 280ms ease; + } + + iframe.loaded { + opacity: 1; + } + + .loading-cover { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 28px; + background: radial-gradient(circle at 50% 38%, rgba(81, 85, 235, 0.16), transparent 38%), #111318; + opacity: 1; + transition: opacity 280ms ease; + } + + .loading-cover.loaded { + opacity: 0; + pointer-events: none; + } + + .loading-status, + .error-status { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + } + + .item-icon { + max-width: 58%; + max-height: 42%; + object-fit: contain; + filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.5)); + user-select: none; + } + + .item-name { + max-width: 720px; + overflow: hidden; + color: rgba(245, 248, 255, 0.72); + font-size: 15px; + font-weight: 600; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + + .progress { + display: flex; + align-items: center; + gap: 10px; + width: min(320px, 64%); + min-height: 18px; + } + + .progress-track { + position: relative; + flex: 1; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.13); + } + + .progress-fill { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background: #5155eb; + box-shadow: 0 0 10px rgba(81, 85, 235, 0.58); + } + + .progress-fill.determinate { + width: 0; + } + + .progress-fill.indeterminate { + width: 38%; + animation: progress-sweep 1.15s ease-in-out infinite; + } + + .progress-value { + width: 4ch; + color: rgba(245, 248, 255, 0.56); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; + } + + .error-message { + max-width: 560px; + color: #ff8585; + font-size: 14px; + line-height: 1.45; + text-align: center; + } + + .error-actions { + display: flex; + gap: 10px; + } + + .error-actions button, + .error-actions a { + padding: 9px 13px; + color: #f5f8ff; + font: inherit; + font-size: 13px; + text-decoration: none; + background: #5155eb; + border: 0; + border-radius: 7px; + cursor: pointer; + } + + .error-actions a { + background: rgba(255, 255, 255, 0.1); + } + + .hidden { + display: none !important; + } + + @keyframes progress-sweep { + from { transform: translateX(-120%); } + to { transform: translateX(320%); } + } + + @media (max-width: 640px) { + dialog { + width: calc(100vw - 20px); + } + + .modal-header { + height: 46px; + padding-left: 12px; + } + + .modal-brand { + display: none; + } + + .viewer-stage { + max-height: calc(100vh - 70px); + } + } +`; + +function createElement(tag: K, className?: string): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (className) element.className = className; + return element; +} + +export class SkinCraftViewerModal { + readonly element = document.createElement('div'); + + private readonly dialog = document.createElement('dialog'); + private readonly title = createElement('span'); + private readonly iframe = document.createElement('iframe'); + private readonly loadingCover = createElement('div', 'loading-cover'); + private readonly loadingStatus = createElement('div', 'loading-status'); + private readonly itemIcon = createElement('img', 'item-icon'); + private readonly itemName = createElement('div', 'item-name'); + private readonly indeterminateProgress = createElement('div', 'progress-fill indeterminate'); + private readonly determinateProgress = createElement('div', 'progress-fill determinate hidden'); + private readonly progressValue = createElement('span', 'progress-value hidden'); + private readonly progress = createElement('div', 'progress'); + private readonly errorStatus = createElement('div', 'error-status hidden'); + private readonly errorMessage = createElement('div', 'error-message'); + private readonly itemLink = createElement('a'); + + constructor( + embedSrc: string, + private readonly onClose: () => void, + private readonly onRetry: () => void + ) { + const shadow = this.element.attachShadow({mode: 'closed'}); + const style = document.createElement('style'); + style.textContent = MODAL_STYLES; + shadow.appendChild(style); + + this.dialog.setAttribute('aria-labelledby', 'skincraft-viewer-title'); + this.dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + this.onClose(); + }); + this.dialog.addEventListener('click', (event) => this.handleDialogClick(event)); + + const header = createElement('header', 'modal-header'); + const titleContainer = createElement('div', 'modal-title'); + titleContainer.id = 'skincraft-viewer-title'; + const brand = createElement('span', 'modal-brand'); + brand.textContent = ' — SkinCraft 3D Viewer'; + titleContainer.append(this.title, brand); + + const closeButton = createElement('button', 'close-button'); + closeButton.type = 'button'; + closeButton.setAttribute('aria-label', 'Close 3D viewer'); + closeButton.textContent = '×'; + closeButton.addEventListener('click', this.onClose); + header.append(titleContainer, closeButton); + + const stage = createElement('div', 'viewer-stage'); + this.iframe.src = embedSrc; + this.iframe.title = 'SkinCraft 3D viewer'; + this.iframe.referrerPolicy = 'no-referrer'; + this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-fullscreen allow-downloads'); + this.iframe.setAttribute('allow', 'fullscreen'); + + this.itemIcon.alt = ''; + this.itemIcon.draggable = false; + this.loadingStatus.append(this.itemIcon, this.itemName, this.progress); + + this.progress.setAttribute('role', 'progressbar'); + this.progress.setAttribute('aria-label', 'Loading 3D viewer'); + this.progress.setAttribute('aria-valuemin', '0'); + this.progress.setAttribute('aria-valuemax', '100'); + const progressTrack = createElement('div', 'progress-track'); + progressTrack.append(this.indeterminateProgress, this.determinateProgress); + this.progress.append(progressTrack, this.progressValue); + + const errorActions = createElement('div', 'error-actions'); + const retryButton = createElement('button'); + retryButton.type = 'button'; + retryButton.textContent = 'Retry'; + retryButton.addEventListener('click', this.onRetry); + this.itemLink.target = '_blank'; + this.itemLink.rel = 'noopener noreferrer'; + this.itemLink.textContent = 'Open on SkinCraft'; + errorActions.append(retryButton, this.itemLink); + this.errorStatus.append(this.errorMessage, errorActions); + + this.loadingCover.append(this.loadingStatus, this.errorStatus); + stage.append(this.iframe, this.loadingCover); + this.dialog.append(header, stage); + shadow.appendChild(this.dialog); + } + + get frameWindow(): Window | null { + return this.iframe.contentWindow; + } + + show(target: SkinCraftViewerTarget): void { + this.title.textContent = target.name; + this.itemName.textContent = target.name; + this.itemLink.href = target.itemUrl; + + if (target.iconUrl) { + this.itemIcon.src = target.iconUrl; + this.itemIcon.classList.remove('hidden'); + } else { + this.itemIcon.removeAttribute('src'); + this.itemIcon.classList.add('hidden'); + } + + this.setLoading(null); + if (!this.dialog.open) this.dialog.showModal(); + requestAnimationFrame(() => this.iframe.focus({preventScroll: true})); + } + + hide(): void { + if (this.dialog.open) this.dialog.close(); + } + + setLoading(value: number | null): void { + this.iframe.classList.remove('loaded'); + this.loadingCover.classList.remove('loaded'); + this.loadingStatus.classList.remove('hidden'); + this.errorStatus.classList.add('hidden'); + + if (value === null) { + this.progress.removeAttribute('aria-valuenow'); + this.indeterminateProgress.classList.remove('hidden'); + this.determinateProgress.classList.add('hidden'); + this.progressValue.classList.add('hidden'); + return; + } + + const displayValue = Math.round(value); + this.progress.setAttribute('aria-valuenow', String(displayValue)); + this.indeterminateProgress.classList.add('hidden'); + this.determinateProgress.classList.remove('hidden'); + this.determinateProgress.style.width = `${value}%`; + this.progressValue.textContent = `${displayValue}%`; + this.progressValue.classList.remove('hidden'); + } + + setLoaded(): void { + this.iframe.classList.add('loaded'); + this.loadingCover.classList.add('loaded'); + requestAnimationFrame(() => this.iframe.focus({preventScroll: true})); + } + + setError(message: string): void { + this.iframe.classList.remove('loaded'); + this.loadingCover.classList.remove('loaded'); + this.loadingStatus.classList.add('hidden'); + this.errorStatus.classList.remove('hidden'); + this.errorMessage.textContent = message; + } + + private handleDialogClick(event: MouseEvent): void { + if (event.target !== this.dialog) return; + + const rect = this.dialog.getBoundingClientRect(); + const inside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + if (!inside) this.onClose(); + } +} diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts new file mode 100644 index 00000000..824a5b07 --- /dev/null +++ b/src/lib/services/skincraft_embed.ts @@ -0,0 +1,233 @@ +import {environment} from '../../environment'; +import {SkinCraftViewerModal} from '../components/inventory/skincraft_viewer_modal'; +import type {SkinCraftViewerTarget} from '../components/inventory/skincraft_viewer_modal'; +import {inPageContext} from '../utils/snips'; +import { + isSkinCraftEmbedEvent, + mergeSkinCraftProgress, + SKINCRAFT_EMBED_MESSAGE_SOURCE, + SKINCRAFT_EMBED_PROTOCOL_VERSION, +} from './skincraft_embed_protocol'; +import type {SkinCraftEmbedCommand} from './skincraft_embed_protocol'; + +const LOAD_TIMEOUT_MS = 20_000; +const OPEN_MESSAGE_SOURCE = 'csfloat-skincraft-viewer' as const; +type LoadPhase = 'idle' | 'loading' | 'loaded' | 'error'; + +export type OpenSkinCraftViewerTarget = { + inspect: string; + name: string; + iconUrl?: string; +}; + +type OpenViewerMessage = { + source: typeof OPEN_MESSAGE_SOURCE; + type: 'open'; + target: OpenSkinCraftViewerTarget; +}; + +function isOpenViewerMessage(data: unknown): data is OpenViewerMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + const target = message.target; + return ( + message.source === OPEN_MESSAGE_SOURCE && + message.type === 'open' && + !!target && + typeof target.inspect === 'string' && + /^[0-9a-f]{40,8192}$/i.test(target.inspect) && + typeof target.name === 'string' && + target.name.length <= 512 && + (target.iconUrl === undefined || + (typeof target.iconUrl === 'string' && + target.iconUrl.length <= 4096 && + target.iconUrl.startsWith('https://community.akamai.steamstatic.com/economy/image/'))) + ); +} + +class SkinCraftEmbedService { + private readonly runsInPage = inPageContext(); + private readonly embedOrigin = new URL(environment.skincraft_embed_origin).origin; + private readonly embedSrc = `${this.embedOrigin}/embed?parentOrigin=${encodeURIComponent(window.location.origin)}`; + + private modal?: SkinCraftViewerModal; + private activeTarget?: SkinCraftViewerTarget; + private ready = false; + private active = false; + private pendingInspect?: string; + private loadSequence = 0; + private latestLoadId?: string; + private loadTimer?: number; + private loadProgress: number | null = null; + private loadPhase: LoadPhase = 'idle'; + + constructor() { + if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); + } + + open(target: OpenSkinCraftViewerTarget): void { + if (!target.inspect) return; + + if (this.runsInPage) { + window.postMessage( + {source: OPEN_MESSAGE_SOURCE, type: 'open', target} satisfies OpenViewerMessage, + window.location.origin + ); + return; + } + + this.openEmbeddedViewer(target); + } + + close(): void { + if (!this.active) return; + + this.active = false; + this.pendingInspect = undefined; + this.latestLoadId = undefined; + this.loadProgress = null; + this.loadPhase = 'idle'; + this.clearLoadTimeout(); + this.post({type: 'clear'}); + this.modal?.hide(); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + } + + private handleOpenRequest = (event: MessageEvent): void => { + if (event.origin !== window.location.origin) return; + if (!isOpenViewerMessage(event.data)) return; + this.openEmbeddedViewer(event.data.target); + }; + + private openEmbeddedViewer(target: OpenSkinCraftViewerTarget): void { + this.active = true; + this.activeTarget = { + inspect: target.inspect, + name: target.name.trim() || 'SkinCraft 3D Viewer', + iconUrl: target.iconUrl, + itemUrl: `${this.embedOrigin}/i/${encodeURIComponent(target.inspect)}`, + }; + this.ensureModal().show(this.activeTarget); + this.requestLoad(target.inspect); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + + private ensureModal(): SkinCraftViewerModal { + if (this.modal?.element.isConnected) return this.modal; + + this.ready = false; + const modal = new SkinCraftViewerModal( + this.embedSrc, + () => this.close(), + () => { + if (this.activeTarget) this.requestLoad(this.activeTarget.inspect); + } + ); + document.body.appendChild(modal.element); + window.addEventListener('message', this.handleEmbedMessage); + this.modal = modal; + return modal; + } + + private requestLoad(inspect: string): void { + this.pendingInspect = inspect; + this.loadProgress = null; + this.loadPhase = 'loading'; + this.modal?.setLoading(null); + this.startLoadTimeout(); + + if (this.ready) { + this.pendingInspect = undefined; + this.sendLoad(inspect); + } + } + + private sendLoad(inspect: string): void { + const id = String(++this.loadSequence); + this.latestLoadId = id; + this.loadProgress = null; + this.loadPhase = 'loading'; + this.modal?.setLoading(null); + this.startLoadTimeout(); + this.post({type: 'load', id, inspect}); + } + + private handleEmbedMessage = (event: MessageEvent): void => { + if (event.origin !== this.embedOrigin || event.source !== this.modal?.frameWindow) return; + if (!isSkinCraftEmbedEvent(event.data)) return; + + const message = event.data; + switch (message.type) { + case 'ready': { + this.ready = true; + if (this.active && this.pendingInspect) { + const inspect = this.pendingInspect; + this.pendingInspect = undefined; + this.sendLoad(inspect); + } + break; + } + case 'progress': { + if (this.loadPhase !== 'loading' || !this.acceptsLoadEvent(message.id)) return; + this.loadProgress = mergeSkinCraftProgress(this.loadProgress, message.percent); + this.modal?.setLoading(this.loadProgress); + this.startLoadTimeout(); + break; + } + case 'loaded': + if (!this.acceptsTerminalEvent(message.id)) return; + this.clearLoadTimeout(); + this.loadPhase = 'loaded'; + this.modal?.setLoaded(); + break; + case 'error': + if (!this.acceptsTerminalEvent(message.id)) return; + this.clearLoadTimeout(); + this.loadPhase = 'error'; + this.modal?.setError(message.message || 'SkinCraft could not load this item.'); + break; + } + }; + + private acceptsLoadEvent(id?: string): boolean { + return this.active && (!id || id === this.latestLoadId); + } + + private acceptsTerminalEvent(id?: string): boolean { + return (this.loadPhase === 'loading' || this.loadPhase === 'error') && this.acceptsLoadEvent(id); + } + + private handleVisibilityChange = (): void => { + if (!this.active) return; + this.post({type: document.hidden ? 'pause' : 'resume'}); + }; + + private post(command: SkinCraftEmbedCommand): void { + this.modal?.frameWindow?.postMessage( + { + source: SKINCRAFT_EMBED_MESSAGE_SOURCE, + v: SKINCRAFT_EMBED_PROTOCOL_VERSION, + ...command, + }, + this.embedOrigin + ); + } + + private startLoadTimeout(): void { + this.clearLoadTimeout(); + this.loadTimer = window.setTimeout(() => { + if (!this.active) return; + this.loadPhase = 'error'; + this.modal?.setError('The 3D viewer took too long to load.'); + }, LOAD_TIMEOUT_MS); + } + + private clearLoadTimeout(): void { + if (this.loadTimer === undefined) return; + window.clearTimeout(this.loadTimer); + this.loadTimer = undefined; + } +} + +export const gSkinCraftEmbed = new SkinCraftEmbedService(); diff --git a/src/lib/services/skincraft_embed_protocol.test.ts b/src/lib/services/skincraft_embed_protocol.test.ts new file mode 100644 index 00000000..30674c3a --- /dev/null +++ b/src/lib/services/skincraft_embed_protocol.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {isSkinCraftEmbedEvent, mergeSkinCraftProgress, normalizeSkinCraftProgress} from './skincraft_embed_protocol'; + +const envelope = {source: 'skincraft-embed', v: 1} as const; + +describe('SkinCraft embed messages', () => { + it('accepts supported messages with the expected envelope', () => { + expect(isSkinCraftEmbedEvent({...envelope, type: 'ready'})).toBe(true); + expect(isSkinCraftEmbedEvent({...envelope, type: 'progress', id: '1', percent: 25})).toBe(true); + expect(isSkinCraftEmbedEvent({...envelope, type: 'loaded', id: '1'})).toBe(true); + expect( + isSkinCraftEmbedEvent({...envelope, type: 'error', id: '1', code: 'load-failed', message: 'Failed'}) + ).toBe(true); + }); + + it('rejects spoofed, malformed, and unknown messages', () => { + expect(isSkinCraftEmbedEvent({source: 'other', v: 1, type: 'ready'})).toBe(false); + expect(isSkinCraftEmbedEvent({source: 'skincraft-embed', v: 2, type: 'ready'})).toBe(false); + expect(isSkinCraftEmbedEvent({...envelope, type: 'error', code: 'failed', message: 5})).toBe(false); + expect(isSkinCraftEmbedEvent({...envelope, type: 'future-event'})).toBe(false); + }); +}); + +describe('SkinCraft embed progress', () => { + it('normalizes untrusted progress values', () => { + expect(normalizeSkinCraftProgress(-4)).toBe(0); + expect(normalizeSkinCraftProgress(40.6)).toBe(41); + expect(normalizeSkinCraftProgress(140)).toBe(100); + expect(normalizeSkinCraftProgress(Number.NaN)).toBeNull(); + expect(normalizeSkinCraftProgress('40')).toBeNull(); + }); + + it('keeps progress monotonic within a load', () => { + expect(mergeSkinCraftProgress(null, 12)).toBe(12); + expect(mergeSkinCraftProgress(60, 32)).toBe(60); + expect(mergeSkinCraftProgress(60, 80)).toBe(80); + expect(mergeSkinCraftProgress(60, null)).toBe(60); + }); +}); diff --git a/src/lib/services/skincraft_embed_protocol.ts b/src/lib/services/skincraft_embed_protocol.ts new file mode 100644 index 00000000..b8ce762d --- /dev/null +++ b/src/lib/services/skincraft_embed_protocol.ts @@ -0,0 +1,59 @@ +export const SKINCRAFT_EMBED_MESSAGE_SOURCE = 'skincraft-embed' as const; +export const SKINCRAFT_EMBED_PROTOCOL_VERSION = 1 as const; + +type EmbedEnvelope = { + source: typeof SKINCRAFT_EMBED_MESSAGE_SOURCE; + v: typeof SKINCRAFT_EMBED_PROTOCOL_VERSION; +}; + +export type SkinCraftEmbedCommand = + | {type: 'load'; id: string; inspect: string} + | {type: 'pause'} + | {type: 'resume'} + | {type: 'clear'}; + +export type SkinCraftEmbedEvent = EmbedEnvelope & + ( + | {type: 'ready'} + | {type: 'progress'; id?: string; percent: number | null; label?: string} + | {type: 'loaded'; id?: string} + | {type: 'error'; id?: string; code: string; message: string} + ); + +export function isSkinCraftEmbedEvent(data: unknown): data is SkinCraftEmbedEvent { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial & Record; + if ( + message.source !== SKINCRAFT_EMBED_MESSAGE_SOURCE || + message.v !== SKINCRAFT_EMBED_PROTOCOL_VERSION || + typeof message.type !== 'string' + ) { + return false; + } + + const hasValidId = message.id === undefined || typeof message.id === 'string'; + switch (message.type) { + case 'ready': + return true; + case 'progress': + return hasValidId && (message.label === undefined || typeof message.label === 'string'); + case 'loaded': + return hasValidId; + case 'error': + return hasValidId && typeof message.code === 'string' && typeof message.message === 'string'; + default: + return false; + } +} + +export function normalizeSkinCraftProgress(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function mergeSkinCraftProgress(current: number | null, incoming: unknown): number | null { + const normalized = normalizeSkinCraftProgress(incoming); + if (normalized === null) return current; + return current === null ? normalized : Math.max(current, normalized); +} From fff89a6e91c626393e442b2de750dd7ee49d320b Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 15:43:53 -0400 Subject: [PATCH 03/17] fix(inventory): animate SkinCraft viewer dialog --- .../inventory/skincraft_viewer_modal.ts | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index 664b6991..ebee5656 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -5,6 +5,8 @@ export type SkinCraftViewerTarget = { itemUrl: string; }; +const MODAL_TRANSITION_MS = 200; + const MODAL_STYLES = ` :host { color: #f5f8ff; @@ -21,11 +23,37 @@ const MODAL_STYLES = ` color: inherit; background: #15171c; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); + opacity: 1; + transform: translateY(0) scale(1); + transform-origin: center; + transition: + opacity 180ms ease, + transform 200ms cubic-bezier(0.22, 1, 0.36, 1); } dialog::backdrop { background: rgba(5, 7, 12, 0.78); backdrop-filter: blur(6px); + opacity: 1; + transition: + opacity 180ms ease, + backdrop-filter 200ms ease; + } + + dialog.entering, + dialog.closing { + opacity: 0; + transform: translateY(10px) scale(0.97); + } + + dialog.entering::backdrop, + dialog.closing::backdrop { + backdrop-filter: blur(0); + opacity: 0; + } + + dialog.closing { + pointer-events: none; } .modal-header { @@ -273,6 +301,8 @@ export class SkinCraftViewerModal { private readonly errorStatus = createElement('div', 'error-status hidden'); private readonly errorMessage = createElement('div', 'error-message'); private readonly itemLink = createElement('a'); + private entryFrame?: number; + private closeTimer?: number; constructor( embedSrc: string, @@ -290,6 +320,9 @@ export class SkinCraftViewerModal { this.onClose(); }); this.dialog.addEventListener('click', (event) => this.handleDialogClick(event)); + this.dialog.addEventListener('transitionend', (event) => { + if (event.target === this.dialog && event.propertyName === 'transform') this.finishClose(); + }); const header = createElement('header', 'modal-header'); const titleContainer = createElement('div', 'modal-title'); @@ -309,7 +342,7 @@ export class SkinCraftViewerModal { this.iframe.src = embedSrc; this.iframe.title = 'SkinCraft 3D viewer'; this.iframe.referrerPolicy = 'no-referrer'; - this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-fullscreen allow-downloads'); + this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads'); this.iframe.setAttribute('allow', 'fullscreen'); this.itemIcon.alt = ''; @@ -359,12 +392,31 @@ export class SkinCraftViewerModal { } this.setLoading(null); - if (!this.dialog.open) this.dialog.showModal(); + this.cancelClose(); + + if (!this.dialog.open) { + this.dialog.classList.add('entering'); + this.dialog.showModal(); + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = undefined; + this.dialog.classList.remove('entering'); + this.iframe.focus({preventScroll: true}); + }); + }); + return; + } + requestAnimationFrame(() => this.iframe.focus({preventScroll: true})); } hide(): void { - if (this.dialog.open) this.dialog.close(); + if (!this.dialog.open || this.dialog.classList.contains('closing')) return; + + this.cancelEntry(); + this.dialog.classList.remove('entering'); + this.dialog.classList.add('closing'); + this.closeTimer = window.setTimeout(() => this.finishClose(), MODAL_TRANSITION_MS); } setLoading(value: number | null): void { @@ -415,4 +467,26 @@ export class SkinCraftViewerModal { event.clientY <= rect.bottom; if (!inside) this.onClose(); } + + private cancelEntry(): void { + if (this.entryFrame === undefined) return; + cancelAnimationFrame(this.entryFrame); + this.entryFrame = undefined; + } + + private cancelClose(): void { + if (this.closeTimer !== undefined) { + window.clearTimeout(this.closeTimer); + this.closeTimer = undefined; + } + this.dialog.classList.remove('closing'); + } + + private finishClose(): void { + if (!this.dialog.classList.contains('closing')) return; + + this.cancelClose(); + this.dialog.classList.remove('entering'); + if (this.dialog.open) this.dialog.close(); + } } From f82c1c35235d08dbef5a52ea1a70c10a7c7cd91e Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 15:52:03 -0400 Subject: [PATCH 04/17] fix(inventory): prevent stale SkinCraft item icons --- .../inventory/skincraft_viewer_modal.ts | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index ebee5656..a0d49d12 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -157,13 +157,22 @@ const MODAL_STYLES = ` } .item-icon { + width: 330px; max-width: 58%; max-height: 42%; + aspect-ratio: 330 / 192; object-fit: contain; filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.5)); + opacity: 1; + transition: opacity 160ms ease-out; user-select: none; } + .item-icon.pending { + opacity: 0; + transition: none; + } + .item-name { max-width: 720px; overflow: hidden; @@ -303,6 +312,7 @@ export class SkinCraftViewerModal { private readonly itemLink = createElement('a'); private entryFrame?: number; private closeTimer?: number; + private iconRequest = 0; constructor( embedSrc: string, @@ -382,14 +392,7 @@ export class SkinCraftViewerModal { this.title.textContent = target.name; this.itemName.textContent = target.name; this.itemLink.href = target.itemUrl; - - if (target.iconUrl) { - this.itemIcon.src = target.iconUrl; - this.itemIcon.classList.remove('hidden'); - } else { - this.itemIcon.removeAttribute('src'); - this.itemIcon.classList.add('hidden'); - } + this.setItemIcon(target.iconUrl); this.setLoading(null); this.cancelClose(); @@ -468,6 +471,29 @@ export class SkinCraftViewerModal { if (!inside) this.onClose(); } + private setItemIcon(iconUrl?: string): void { + const request = ++this.iconRequest; + this.itemIcon.classList.add('pending'); + + if (!iconUrl) { + this.itemIcon.removeAttribute('src'); + this.itemIcon.classList.add('hidden'); + return; + } + + this.itemIcon.classList.remove('hidden'); + this.itemIcon.src = iconUrl; + void this.itemIcon + .decode() + .then(() => { + if (request !== this.iconRequest || this.itemIcon.src !== iconUrl) return; + requestAnimationFrame(() => { + if (request === this.iconRequest) this.itemIcon.classList.remove('pending'); + }); + }) + .catch(() => undefined); + } + private cancelEntry(): void { if (this.entryFrame === undefined) return; cancelAnimationFrame(this.entryFrame); From 76572b9b3a57dd31edeb77b1f12d6ffaf1f3b7f0 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 16:12:44 -0400 Subject: [PATCH 05/17] feat(inventory): add responsive SkinCraft item navigation --- .../inventory/selected_item_info.ts | 1 + .../inventory/skincraft_viewer_modal.ts | 283 +++++++++++++++++- src/lib/services/skincraft_embed.ts | 97 +++--- .../skincraft_inventory_targets.test.ts | 24 ++ .../services/skincraft_inventory_targets.ts | 60 ++++ .../skincraft_viewer_protocol.test.ts | 57 ++++ src/lib/services/skincraft_viewer_protocol.ts | 47 +++ 7 files changed, 514 insertions(+), 55 deletions(-) create mode 100644 src/lib/services/skincraft_inventory_targets.test.ts create mode 100644 src/lib/services/skincraft_inventory_targets.ts create mode 100644 src/lib/services/skincraft_viewer_protocol.test.ts create mode 100644 src/lib/services/skincraft_viewer_protocol.ts diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index 0680ea04..ca6dd58e 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -320,6 +320,7 @@ export class SelectedItemInfo extends FloatElement { inspect: this.skinCraftInspect, name: this.asset.description.market_hash_name, iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, + assetId: this.asset.assetid, }); } diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index a0d49d12..bbac5c55 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -3,6 +3,7 @@ export type SkinCraftViewerTarget = { name: string; iconUrl?: string; itemUrl: string; + assetId?: string; }; const MODAL_TRANSITION_MS = 200; @@ -56,6 +57,10 @@ const MODAL_STYLES = ` pointer-events: none; } + .modal-body { + min-width: 0; + } + .modal-header { display: flex; align-items: center; @@ -104,6 +109,106 @@ const MODAL_STYLES = ` background: rgba(255, 255, 255, 0.09); } + .inventory-panel { + display: none; + min-width: 0; + min-height: 0; + background: #181b21; + } + + .inventory-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 14px; + color: rgba(245, 248, 255, 0.82); + font-size: 13px; + font-weight: 600; + } + + .inventory-count { + color: rgba(245, 248, 255, 0.42); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 500; + } + + .inventory-grid { + min-width: 0; + min-height: 0; + scrollbar-color: rgba(193, 206, 255, 0.2) transparent; + scrollbar-width: thin; + } + + .inventory-grid::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .inventory-grid::-webkit-scrollbar-thumb { + background: rgba(193, 206, 255, 0.2); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .inventory-card { + position: relative; + box-sizing: border-box; + min-width: 0; + padding: 5px; + overflow: hidden; + color: inherit; + background-color: rgba(42, 47, 58, 0.82); + border: 1px solid rgba(193, 206, 255, 0.08); + border-radius: 7px; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 100ms ease; + } + + .inventory-card:hover { + background-color: rgba(54, 61, 76, 0.92); + border-color: rgba(193, 206, 255, 0.22); + } + + .inventory-card:focus-visible { + outline: 2px solid rgba(132, 136, 255, 0.95); + outline-offset: 1px; + } + + .inventory-card:active { + transform: scale(0.97); + } + + .inventory-card.selected { + background-color: rgba(62, 65, 134, 0.72); + border-color: rgba(132, 136, 255, 0.9); + box-shadow: + 0 0 0 1px rgba(81, 85, 235, 0.38), + 0 6px 18px rgba(0, 0, 0, 0.28); + } + + .inventory-card img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0.78; + pointer-events: none; + transition: opacity 140ms ease; + } + + .inventory-card:hover img, + .inventory-card.selected img { + opacity: 1; + } + .viewer-stage { position: relative; aspect-ratio: 16 / 9; @@ -267,6 +372,83 @@ const MODAL_STYLES = ` to { transform: translateX(320%); } } + @media (min-width: 1168px) and (max-width: 1519px) and (min-height: 840px) { + dialog.has-inventory { + width: 1120px; + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-rows: 122px auto; + } + + dialog.has-inventory .inventory-panel { + display: grid; + grid-template-columns: 122px minmax(0, 1fr); + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + border-right: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: flex; + gap: 8px; + padding: 11px; + overflow-x: auto; + overflow-y: hidden; + } + + dialog.has-inventory .inventory-card { + flex: 0 0 98px; + height: 98px; + } + } + + @media (min-width: 1520px) { + dialog.has-inventory { + width: min(1440px, calc(100vw - 48px)); + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-columns: minmax(280px, 320px) 1120px; + } + + dialog.has-inventory .inventory-panel { + display: flex; + flex-direction: column; + height: min(630px, calc(100vh - 104px)); + border-right: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + height: 48px; + border-bottom: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: grid; + flex: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: 92px; + align-content: start; + gap: 8px; + padding: 10px; + overflow-x: hidden; + overflow-y: auto; + } + + dialog.has-inventory .inventory-card { + height: 92px; + } + + dialog.has-inventory .viewer-stage { + width: 1120px; + } + } + @media (max-width: 640px) { dialog { width: calc(100vw - 20px); @@ -310,6 +492,11 @@ export class SkinCraftViewerModal { private readonly errorStatus = createElement('div', 'error-status hidden'); private readonly errorMessage = createElement('div', 'error-message'); private readonly itemLink = createElement('a'); + private readonly inventoryCount = createElement('span', 'inventory-count'); + private readonly inventoryGrid = createElement('div', 'inventory-grid'); + private readonly inventoryButtons = new Map(); + private inventoryTargets: SkinCraftViewerTarget[] = []; + private activeInventoryButton?: HTMLButtonElement; private entryFrame?: number; private closeTimer?: number; private iconRequest = 0; @@ -317,7 +504,8 @@ export class SkinCraftViewerModal { constructor( embedSrc: string, private readonly onClose: () => void, - private readonly onRetry: () => void + private readonly onRetry: () => void, + private readonly onSelect: (target: SkinCraftViewerTarget) => void ) { const shadow = this.element.attachShadow({mode: 'closed'}); const style = document.createElement('style'); @@ -348,6 +536,15 @@ export class SkinCraftViewerModal { closeButton.addEventListener('click', this.onClose); header.append(titleContainer, closeButton); + const inventoryPanel = createElement('aside', 'inventory-panel'); + inventoryPanel.setAttribute('aria-label', 'Loaded inventory items'); + const inventoryHeader = createElement('div', 'inventory-panel-header'); + const inventoryLabel = createElement('span'); + inventoryLabel.textContent = 'Inventory'; + inventoryHeader.append(inventoryLabel, this.inventoryCount); + inventoryPanel.append(inventoryHeader, this.inventoryGrid); + this.inventoryGrid.addEventListener('click', (event) => this.handleInventoryClick(event)); + const stage = createElement('div', 'viewer-stage'); this.iframe.src = embedSrc; this.iframe.title = 'SkinCraft 3D viewer'; @@ -380,7 +577,9 @@ export class SkinCraftViewerModal { this.loadingCover.append(this.loadingStatus, this.errorStatus); stage.append(this.iframe, this.loadingCover); - this.dialog.append(header, stage); + const modalBody = createElement('div', 'modal-body'); + modalBody.append(inventoryPanel, stage); + this.dialog.append(header, modalBody); shadow.appendChild(this.dialog); } @@ -388,13 +587,50 @@ export class SkinCraftViewerModal { return this.iframe.contentWindow; } + get isOpen(): boolean { + return this.dialog.open; + } + + setInventory(targets: SkinCraftViewerTarget[]): void { + this.inventoryTargets = targets; + this.inventoryButtons.clear(); + this.activeInventoryButton = undefined; + const fragment = document.createDocumentFragment(); + + for (const [index, target] of targets.entries()) { + const button = createElement('button', 'inventory-card'); + button.type = 'button'; + button.title = target.name; + button.dataset.index = String(index); + button.setAttribute('aria-label', `View ${target.name} in 3D`); + button.setAttribute('aria-pressed', 'false'); + + if (target.iconUrl) { + const icon = createElement('img'); + icon.src = target.iconUrl; + icon.alt = ''; + icon.loading = 'lazy'; + icon.decoding = 'async'; + icon.draggable = false; + button.appendChild(icon); + } + + this.inventoryButtons.set(this.getTargetKey(target), button); + fragment.appendChild(button); + } + + this.inventoryGrid.replaceChildren(fragment); + this.inventoryCount.textContent = String(targets.length); + this.dialog.classList.toggle('has-inventory', targets.length > 1); + } + show(target: SkinCraftViewerTarget): void { this.title.textContent = target.name; this.itemName.textContent = target.name; this.itemLink.href = target.itemUrl; this.setItemIcon(target.iconUrl); + this.setActiveInventoryItem(target); - this.setLoading(null); this.cancelClose(); if (!this.dialog.open) { @@ -407,10 +643,7 @@ export class SkinCraftViewerModal { this.iframe.focus({preventScroll: true}); }); }); - return; } - - requestAnimationFrame(() => this.iframe.focus({preventScroll: true})); } hide(): void { @@ -448,7 +681,11 @@ export class SkinCraftViewerModal { setLoaded(): void { this.iframe.classList.add('loaded'); this.loadingCover.classList.add('loaded'); - requestAnimationFrame(() => this.iframe.focus({preventScroll: true})); + } + + continueLoadingInFrame(): void { + this.iframe.classList.add('loaded'); + this.loadingCover.classList.add('loaded'); } setError(message: string): void { @@ -471,6 +708,18 @@ export class SkinCraftViewerModal { if (!inside) this.onClose(); } + private handleInventoryClick(event: MouseEvent): void { + const target = event.target; + if (!(target instanceof Element)) return; + + const button = target.closest('.inventory-card'); + const index = Number(button?.dataset.index); + if (!button || !Number.isInteger(index)) return; + + const inventoryTarget = this.inventoryTargets[index]; + if (inventoryTarget) this.onSelect(inventoryTarget); + } + private setItemIcon(iconUrl?: string): void { const request = ++this.iconRequest; this.itemIcon.classList.add('pending'); @@ -494,6 +743,26 @@ export class SkinCraftViewerModal { .catch(() => undefined); } + private getTargetKey(target: SkinCraftViewerTarget): string { + return target.assetId || target.inspect; + } + + private setActiveInventoryItem(target: SkinCraftViewerTarget): void { + this.activeInventoryButton?.classList.remove('selected'); + this.activeInventoryButton?.setAttribute('aria-pressed', 'false'); + + const button = this.inventoryButtons.get(this.getTargetKey(target)); + if (!button) { + this.activeInventoryButton = undefined; + return; + } + + button.classList.add('selected'); + button.setAttribute('aria-pressed', 'true'); + this.activeInventoryButton = button; + requestAnimationFrame(() => button.scrollIntoView({block: 'nearest', inline: 'nearest'})); + } + private cancelEntry(): void { if (this.entryFrame === undefined) return; cancelAnimationFrame(this.entryFrame); diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 824a5b07..8a73ab34 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -9,43 +9,13 @@ import { SKINCRAFT_EMBED_PROTOCOL_VERSION, } from './skincraft_embed_protocol'; import type {SkinCraftEmbedCommand} from './skincraft_embed_protocol'; +import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; +import {isOpenSkinCraftViewerMessage, SKINCRAFT_VIEWER_MESSAGE_SOURCE} from './skincraft_viewer_protocol'; +import type {OpenSkinCraftViewerMessage, OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; const LOAD_TIMEOUT_MS = 20_000; -const OPEN_MESSAGE_SOURCE = 'csfloat-skincraft-viewer' as const; type LoadPhase = 'idle' | 'loading' | 'loaded' | 'error'; -export type OpenSkinCraftViewerTarget = { - inspect: string; - name: string; - iconUrl?: string; -}; - -type OpenViewerMessage = { - source: typeof OPEN_MESSAGE_SOURCE; - type: 'open'; - target: OpenSkinCraftViewerTarget; -}; - -function isOpenViewerMessage(data: unknown): data is OpenViewerMessage { - if (!data || typeof data !== 'object') return false; - - const message = data as Partial; - const target = message.target; - return ( - message.source === OPEN_MESSAGE_SOURCE && - message.type === 'open' && - !!target && - typeof target.inspect === 'string' && - /^[0-9a-f]{40,8192}$/i.test(target.inspect) && - typeof target.name === 'string' && - target.name.length <= 512 && - (target.iconUrl === undefined || - (typeof target.iconUrl === 'string' && - target.iconUrl.length <= 4096 && - target.iconUrl.startsWith('https://community.akamai.steamstatic.com/economy/image/'))) - ); -} - class SkinCraftEmbedService { private readonly runsInPage = inPageContext(); private readonly embedOrigin = new URL(environment.skincraft_embed_origin).origin; @@ -61,6 +31,7 @@ class SkinCraftEmbedService { private loadTimer?: number; private loadProgress: number | null = null; private loadPhase: LoadPhase = 'idle'; + private showLoadingCover = false; constructor() { if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); @@ -71,13 +42,21 @@ class SkinCraftEmbedService { if (this.runsInPage) { window.postMessage( - {source: OPEN_MESSAGE_SOURCE, type: 'open', target} satisfies OpenViewerMessage, + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: + typeof g_ActiveInventory === 'undefined' || !g_ActiveInventory + ? [] + : getLoadedInventoryTargets(g_ActiveInventory), + } satisfies OpenSkinCraftViewerMessage, window.location.origin ); return; } - this.openEmbeddedViewer(target); + this.openEmbeddedViewer(target, []); } close(): void { @@ -88,6 +67,7 @@ class SkinCraftEmbedService { this.latestLoadId = undefined; this.loadProgress = null; this.loadPhase = 'idle'; + this.showLoadingCover = false; this.clearLoadTimeout(); this.post({type: 'clear'}); this.modal?.hide(); @@ -96,21 +76,34 @@ class SkinCraftEmbedService { private handleOpenRequest = (event: MessageEvent): void => { if (event.origin !== window.location.origin) return; - if (!isOpenViewerMessage(event.data)) return; - this.openEmbeddedViewer(event.data.target); + if (!isOpenSkinCraftViewerMessage(event.data)) return; + this.openEmbeddedViewer(event.data.target, event.data.inventory); }; - private openEmbeddedViewer(target: OpenSkinCraftViewerTarget): void { + private openEmbeddedViewer(target: OpenSkinCraftViewerTarget, inventory: OpenSkinCraftViewerTarget[]): void { + const modal = this.ensureModal(); + modal.setInventory(inventory.map((item) => this.toModalTarget(item))); + this.selectEmbeddedTarget(this.toModalTarget(target)); + } + + private selectEmbeddedTarget(target: SkinCraftViewerTarget): void { + const modal = this.ensureModal(); + const showLoadingCover = !modal.isOpen; this.active = true; - this.activeTarget = { + this.activeTarget = target; + modal.show(this.activeTarget); + this.requestLoad(target.inspect, showLoadingCover); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + + private toModalTarget(target: OpenSkinCraftViewerTarget): SkinCraftViewerTarget { + return { inspect: target.inspect, name: target.name.trim() || 'SkinCraft 3D Viewer', iconUrl: target.iconUrl, itemUrl: `${this.embedOrigin}/i/${encodeURIComponent(target.inspect)}`, + assetId: target.assetId, }; - this.ensureModal().show(this.activeTarget); - this.requestLoad(target.inspect); - document.addEventListener('visibilitychange', this.handleVisibilityChange); } private ensureModal(): SkinCraftViewerModal { @@ -121,8 +114,9 @@ class SkinCraftEmbedService { this.embedSrc, () => this.close(), () => { - if (this.activeTarget) this.requestLoad(this.activeTarget.inspect); - } + if (this.activeTarget) this.requestLoad(this.activeTarget.inspect, true); + }, + (target) => this.selectEmbeddedTarget(target) ); document.body.appendChild(modal.element); window.addEventListener('message', this.handleEmbedMessage); @@ -130,11 +124,16 @@ class SkinCraftEmbedService { return modal; } - private requestLoad(inspect: string): void { + private requestLoad(inspect: string, showLoadingCover: boolean): void { this.pendingInspect = inspect; this.loadProgress = null; this.loadPhase = 'loading'; - this.modal?.setLoading(null); + this.showLoadingCover = showLoadingCover; + if (showLoadingCover) { + this.modal?.setLoading(null); + } else { + this.modal?.continueLoadingInFrame(); + } this.startLoadTimeout(); if (this.ready) { @@ -148,7 +147,6 @@ class SkinCraftEmbedService { this.latestLoadId = id; this.loadProgress = null; this.loadPhase = 'loading'; - this.modal?.setLoading(null); this.startLoadTimeout(); this.post({type: 'load', id, inspect}); } @@ -171,7 +169,7 @@ class SkinCraftEmbedService { case 'progress': { if (this.loadPhase !== 'loading' || !this.acceptsLoadEvent(message.id)) return; this.loadProgress = mergeSkinCraftProgress(this.loadProgress, message.percent); - this.modal?.setLoading(this.loadProgress); + if (this.showLoadingCover) this.modal?.setLoading(this.loadProgress); this.startLoadTimeout(); break; } @@ -179,12 +177,14 @@ class SkinCraftEmbedService { if (!this.acceptsTerminalEvent(message.id)) return; this.clearLoadTimeout(); this.loadPhase = 'loaded'; + this.showLoadingCover = false; this.modal?.setLoaded(); break; case 'error': if (!this.acceptsTerminalEvent(message.id)) return; this.clearLoadTimeout(); this.loadPhase = 'error'; + this.showLoadingCover = false; this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; } @@ -219,6 +219,7 @@ class SkinCraftEmbedService { this.loadTimer = window.setTimeout(() => { if (!this.active) return; this.loadPhase = 'error'; + this.showLoadingCover = false; this.modal?.setError('The 3D viewer took too long to load.'); }, LOAD_TIMEOUT_MS); } diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts new file mode 100644 index 00000000..f3dbd178 --- /dev/null +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -0,0 +1,24 @@ +import {describe, expect, it} from 'vitest'; +import type {CInventory, InventoryAsset} from '../types/steam'; +import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; + +function createInventory(assets: InventoryAsset[]): CInventory { + return { + initialized: true, + m_rgAssetProperties: {}, + m_rgAssets: Object.fromEntries(assets.map((asset) => [asset.assetid, asset])), + m_parentInventory: null, + rgInventory: {}, + }; +} + +describe('SkinCraft inventory targets', () => { + it('skips Steam assets whose descriptions are not initialized yet', () => { + const pendingAsset = { + assetid: '123', + description: undefined, + } as unknown as InventoryAsset; + + expect(getLoadedInventoryTargets(createInventory([pendingAsset]))).toEqual([]); + }); +}); diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts new file mode 100644 index 00000000..750c5268 --- /dev/null +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -0,0 +1,60 @@ +import type {CAppwideInventory, CInventory, InventoryAsset, rgAssetProperty} from '../types/steam'; +import {ContextId} from '../types/steam_constants'; +import {isCAppwideInventory} from '../utils/checkers'; +import {isSkin} from '../utils/skin'; +import {MAX_SKINCRAFT_INVENTORY_TARGETS} from './skincraft_viewer_protocol'; +import type {OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; + +function getAssetProperties(inventory: CInventory, asset: InventoryAsset): rgAssetProperty[] { + if (asset.asset_properties?.length) return asset.asset_properties; + if (asset.description.asset_properties?.length) return asset.description.asset_properties; + return inventory.m_rgAssetProperties[asset.assetid] || []; +} + +function toViewerTarget(inventory: CInventory, asset: InventoryAsset): OpenSkinCraftViewerTarget | undefined { + if (!asset.description || typeof asset.description.market_hash_name !== 'string') return; + if (!isSkin(asset.description)) return; + + const inspect = getAssetProperties(inventory, asset) + .find((property) => property.propertyid === 6) + ?.string_value?.trim(); + if (!inspect || !/^[0-9a-f]{40,8192}$/i.test(inspect)) return; + + const icon = asset.description.icon_url_large || asset.description.icon_url; + return { + inspect, + name: asset.description.market_hash_name, + iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, + assetId: asset.assetid, + }; +} + +export function getLoadedInventoryTargets( + activeInventory: CInventory | CAppwideInventory +): OpenSkinCraftViewerTarget[] { + const inventories = isCAppwideInventory(activeInventory) + ? [ + activeInventory.m_rgChildInventories[ContextId.PRIMARY], + activeInventory.m_rgChildInventories[ContextId.PROTECTED], + ] + : [activeInventory]; + const targets: OpenSkinCraftViewerTarget[] = []; + const seenAssets = new Set(); + + for (const inventory of inventories) { + if (!inventory) continue; + + for (const asset of Object.values(inventory.m_rgAssets)) { + if (seenAssets.has(asset.assetid)) continue; + + const target = toViewerTarget(inventory, asset); + if (!target) continue; + + seenAssets.add(asset.assetid); + targets.push(target); + if (targets.length === MAX_SKINCRAFT_INVENTORY_TARGETS) return targets; + } + } + + return targets; +} diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts new file mode 100644 index 00000000..de422269 --- /dev/null +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from 'vitest'; +import { + isOpenSkinCraftViewerMessage, + MAX_SKINCRAFT_INVENTORY_TARGETS, + SKINCRAFT_VIEWER_MESSAGE_SOURCE, +} from './skincraft_viewer_protocol'; +import type {OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; + +const target: OpenSkinCraftViewerTarget = { + inspect: 'a'.repeat(80), + name: 'AK-47 | Redline', + iconUrl: 'https://community.akamai.steamstatic.com/economy/image/example/330x192', + assetId: '12345678901234567890', +}; + +describe('SkinCraft viewer open messages', () => { + it('accepts a bounded inventory snapshot of valid targets', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [target], + }) + ).toBe(true); + }); + + it('rejects malformed targets and unexpected image origins', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, inspect: 'not-an-inspect'}, + inventory: [], + }) + ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [{...target, iconUrl: 'https://example.com/item.png'}], + }) + ).toBe(false); + }); + + it('rejects oversized inventory snapshots', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: Array.from({length: MAX_SKINCRAFT_INVENTORY_TARGETS + 1}, () => target), + }) + ).toBe(false); + }); +}); diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts new file mode 100644 index 00000000..507803c6 --- /dev/null +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -0,0 +1,47 @@ +export const SKINCRAFT_VIEWER_MESSAGE_SOURCE = 'csfloat-skincraft-viewer' as const; +export const MAX_SKINCRAFT_INVENTORY_TARGETS = 2_000; + +export type OpenSkinCraftViewerTarget = { + inspect: string; + name: string; + iconUrl?: string; + assetId?: string; +}; + +export type OpenSkinCraftViewerMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'open'; + target: OpenSkinCraftViewerTarget; + inventory: OpenSkinCraftViewerTarget[]; +}; + +function isViewerTarget(data: unknown): data is OpenSkinCraftViewerTarget { + if (!data || typeof data !== 'object') return false; + + const target = data as Partial; + return ( + typeof target.inspect === 'string' && + /^[0-9a-f]{40,8192}$/i.test(target.inspect) && + typeof target.name === 'string' && + target.name.length <= 512 && + (target.assetId === undefined || (typeof target.assetId === 'string' && /^\d{1,32}$/.test(target.assetId))) && + (target.iconUrl === undefined || + (typeof target.iconUrl === 'string' && + target.iconUrl.length <= 4096 && + target.iconUrl.startsWith('https://community.akamai.steamstatic.com/economy/image/'))) + ); +} + +export function isOpenSkinCraftViewerMessage(data: unknown): data is OpenSkinCraftViewerMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'open' && + isViewerTarget(message.target) && + Array.isArray(message.inventory) && + message.inventory.length <= MAX_SKINCRAFT_INVENTORY_TARGETS && + message.inventory.every(isViewerTarget) + ); +} From 8aaead32bfb02ec4db617321acfb8f31824ff499 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 20 Jul 2026 16:43:32 -0400 Subject: [PATCH 06/17] feat(inventory): refine SkinCraft inventory navigation --- .../inventory/selected_item_info.ts | 22 ++++-- .../inventory/skincraft_viewer_modal.ts | 75 +++++++++++++++++-- src/lib/services/float_fetcher.ts | 4 + src/lib/services/skincraft_embed.ts | 4 + .../skincraft_inventory_targets.test.ts | 49 +++++++++++- .../services/skincraft_inventory_targets.ts | 44 ++++++++--- .../skincraft_viewer_protocol.test.ts | 12 +++ src/lib/services/skincraft_viewer_protocol.ts | 10 +++ src/lib/types/steam.d.ts | 1 + 9 files changed, 195 insertions(+), 26 deletions(-) diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index ca6dd58e..fa76ed48 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -24,6 +24,7 @@ import '../common/ui/floatbar'; import {ClientSend} from '../../bridge/client'; import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem'; import {gSkinCraftEmbed} from '../../services/skincraft_embed'; +import {getSkinCraftInspect} from '../../services/skincraft_inventory_targets'; import './list_item_modal'; /** @@ -194,13 +195,17 @@ export class SelectedItemInfo extends FloatElement { ); } - if (isSellableOnCSFloat(this.asset.description)) { + const isMarketItem = isSellableOnCSFloat(this.asset.description); + if ((isMarketItem && this.canListOnCSFloat) || this.canViewInSkinCraft) { containerChildren.push( - html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
- ${this.renderFloatMarketListing()}` + html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
` ); } + if (isMarketItem) { + containerChildren.push(this.renderFloatMarketListing()); + } + if (containerChildren.length === 0) { return html``; } @@ -271,14 +276,15 @@ export class SelectedItemInfo extends FloatElement { } private get skinCraftInspect(): string | undefined { - return ( - this.asset?.asset_properties?.find((property) => property.propertyid === 6)?.string_value?.trim() || - undefined - ); + return getSkinCraftInspect(this.asset); + } + + private get canViewInSkinCraft(): boolean { + return !!this.skinCraftInspect; } renderViewIn3D(): TemplateResult<1> { - if (!this.canListOnCSFloat || !this.asset || !isSkin(this.asset.description) || !this.skinCraftInspect) { + if (!this.canViewInSkinCraft) { return html``; } diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index bbac5c55..71d8505d 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -4,6 +4,10 @@ export type SkinCraftViewerTarget = { iconUrl?: string; itemUrl: string; assetId?: string; + seed?: string; + float?: string; + rarityColor?: string; + backgroundColor?: string; }; const MODAL_TRANSITION_MS = 200; @@ -161,8 +165,8 @@ const MODAL_STYLES = ` padding: 5px; overflow: hidden; color: inherit; - background-color: rgba(42, 47, 58, 0.82); - border: 1px solid rgba(193, 206, 255, 0.08); + background-color: var(--inventory-card-background, #2a2f3a); + border: 1px solid var(--inventory-card-rarity, rgba(193, 206, 255, 0.18)); border-radius: 7px; cursor: pointer; transition: @@ -173,8 +177,7 @@ const MODAL_STYLES = ` } .inventory-card:hover { - background-color: rgba(54, 61, 76, 0.92); - border-color: rgba(193, 206, 255, 0.22); + background-color: var(--inventory-card-hover, #363d4c); } .inventory-card:focus-visible { @@ -187,10 +190,9 @@ const MODAL_STYLES = ` } .inventory-card.selected { - background-color: rgba(62, 65, 134, 0.72); - border-color: rgba(132, 136, 255, 0.9); + background-color: var(--inventory-card-selected, #3e4186); box-shadow: - 0 0 0 1px rgba(81, 85, 235, 0.38), + 0 0 0 2px rgba(132, 136, 255, 0.92), 0 6px 18px rgba(0, 0, 0, 0.28); } @@ -209,6 +211,32 @@ const MODAL_STYLES = ` opacity: 1; } + .inventory-card-seed, + .inventory-card-float { + position: absolute; + right: 5px; + z-index: 1; + max-width: calc(100% - 10px); + overflow: hidden; + color: rgba(225, 230, 239, 0.66); + font-family: Arial, Helvetica, sans-serif; + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 1; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); + white-space: nowrap; + pointer-events: none; + } + + .inventory-card-seed { + top: 5px; + } + + .inventory-card-float { + bottom: 5px; + } + .viewer-stage { position: relative; aspect-ratio: 16 / 9; @@ -475,6 +503,16 @@ function createElement(tag: K, className? return element; } +function mixHexColors(base: string, tint: string, tintAmount: number): string { + const mixChannel = (offset: number): number => { + const baseChannel = Number.parseInt(base.slice(offset, offset + 2), 16); + const tintChannel = Number.parseInt(tint.slice(offset, offset + 2), 16); + return Math.round(baseChannel + (tintChannel - baseChannel) * tintAmount); + }; + + return `rgb(${mixChannel(0)} ${mixChannel(2)} ${mixChannel(4)})`; +} + export class SkinCraftViewerModal { readonly element = document.createElement('div'); @@ -604,6 +642,7 @@ export class SkinCraftViewerModal { button.dataset.index = String(index); button.setAttribute('aria-label', `View ${target.name} in 3D`); button.setAttribute('aria-pressed', 'false'); + this.setInventoryCardColors(button, target); if (target.iconUrl) { const icon = createElement('img'); @@ -615,6 +654,18 @@ export class SkinCraftViewerModal { button.appendChild(icon); } + if (target.seed) { + const seed = createElement('span', 'inventory-card-seed'); + seed.textContent = target.seed; + button.appendChild(seed); + } + + if (target.float) { + const float = createElement('span', 'inventory-card-float'); + float.textContent = target.float; + button.appendChild(float); + } + this.inventoryButtons.set(this.getTargetKey(target), button); fragment.appendChild(button); } @@ -720,6 +771,16 @@ export class SkinCraftViewerModal { if (inventoryTarget) this.onSelect(inventoryTarget); } + private setInventoryCardColors(button: HTMLButtonElement, target: SkinCraftViewerTarget): void { + const base = target.backgroundColor || '2a2f3a'; + const rarity = target.rarityColor || 'c1ceff'; + const background = target.backgroundColor ? `#${base}` : mixHexColors(base, rarity, 0.1); + button.style.setProperty('--inventory-card-background', background); + button.style.setProperty('--inventory-card-rarity', `#${rarity}`); + button.style.setProperty('--inventory-card-hover', mixHexColors(base, rarity, 0.16)); + button.style.setProperty('--inventory-card-selected', mixHexColors(base, rarity, 0.26)); + } + private setItemIcon(iconUrl?: string): void { const request = ++this.iconRequest; this.itemIcon.classList.add('pending'); diff --git a/src/lib/services/float_fetcher.ts b/src/lib/services/float_fetcher.ts index 33ec1fea..74d40ccf 100644 --- a/src/lib/services/float_fetcher.ts +++ b/src/lib/services/float_fetcher.ts @@ -45,6 +45,10 @@ class FloatFetcher { return deferred.promise(); } + getCached(assetId: string): ItemInfo | undefined { + return this.cache.get(assetId); + } + private async flush() { this.flushScheduled = false; diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 8a73ab34..6617d45f 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -103,6 +103,10 @@ class SkinCraftEmbedService { iconUrl: target.iconUrl, itemUrl: `${this.embedOrigin}/i/${encodeURIComponent(target.inspect)}`, assetId: target.assetId, + seed: target.seed, + float: target.float, + rarityColor: target.rarityColor, + backgroundColor: target.backgroundColor, }; } diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts index f3dbd178..c80817ac 100644 --- a/src/lib/services/skincraft_inventory_targets.test.ts +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -1,6 +1,7 @@ import {describe, expect, it} from 'vitest'; +import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import type {CInventory, InventoryAsset} from '../types/steam'; -import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; +import {getLoadedInventoryTargets, getSkinCraftInspect} from './skincraft_inventory_targets'; function createInventory(assets: InventoryAsset[]): CInventory { return { @@ -13,6 +14,20 @@ function createInventory(assets: InventoryAsset[]): CInventory { } describe('SkinCraft inventory targets', () => { + it('allows skins with inspect data to be viewed independently of listing eligibility', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + market_hash_name: 'AK-47 | Redline (Field-Tested)', + tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], + tradable: 0, + }, + } as unknown as InventoryAsset; + + expect(getSkinCraftInspect(asset)).toBe('a'.repeat(80)); + }); + it('skips Steam assets whose descriptions are not initialized yet', () => { const pendingAsset = { assetid: '123', @@ -21,4 +36,36 @@ describe('SkinCraft inventory targets', () => { expect(getLoadedInventoryTargets(createInventory([pendingAsset]))).toEqual([]); }); + + it('includes cached float metadata and Steam rarity colors', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + background_color: '20242d', + icon_url: 'icon', + icon_url_large: 'large-icon', + market_hash_name: 'USP-S | Sleeping Potion (Factory New)', + tags: [ + {category: 'Weapon', internal_name: 'weapon_usp_silencer'}, + {category: 'Rarity', internal_name: 'Rarity_Mythical', color: '8847ff'}, + ], + }, + } as unknown as InventoryAsset; + const itemInfo = { + paintindex: 0, + paintseed: 977, + floatvalue: 0.2953754, + } as ItemInfo; + + expect(getLoadedInventoryTargets(createInventory([asset]), () => itemInfo)).toEqual([ + expect.objectContaining({ + assetId: '123', + seed: '977', + float: '0.295375', + rarityColor: '8847ff', + backgroundColor: '20242d', + }), + ]); + }); }); diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 750c5268..44309a49 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -1,36 +1,60 @@ import type {CAppwideInventory, CInventory, InventoryAsset, rgAssetProperty} from '../types/steam'; +import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import {ContextId} from '../types/steam_constants'; import {isCAppwideInventory} from '../utils/checkers'; -import {isSkin} from '../utils/skin'; +import {formatFloatWithRank, formatSeed, isSkin} from '../utils/skin'; +import {gFloatFetcher} from './float_fetcher'; import {MAX_SKINCRAFT_INVENTORY_TARGETS} from './skincraft_viewer_protocol'; import type {OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; -function getAssetProperties(inventory: CInventory, asset: InventoryAsset): rgAssetProperty[] { +function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetProperty[]): rgAssetProperty[] { if (asset.asset_properties?.length) return asset.asset_properties; if (asset.description.asset_properties?.length) return asset.description.asset_properties; - return inventory.m_rgAssetProperties[asset.assetid] || []; + return fallbackProperties; } -function toViewerTarget(inventory: CInventory, asset: InventoryAsset): OpenSkinCraftViewerTarget | undefined { +export function getSkinCraftInspect( + asset: InventoryAsset | undefined, + fallbackProperties: rgAssetProperty[] = [] +): string | undefined { + if (!asset?.description || !isSkin(asset.description)) return; + + return ( + getAssetProperties(asset, fallbackProperties) + .find((property) => property.propertyid === 6) + ?.string_value?.trim() || undefined + ); +} + +function toViewerTarget( + inventory: CInventory, + asset: InventoryAsset, + getCachedItemInfo: (assetId: string) => ItemInfo | undefined +): OpenSkinCraftViewerTarget | undefined { if (!asset.description || typeof asset.description.market_hash_name !== 'string') return; - if (!isSkin(asset.description)) return; - const inspect = getAssetProperties(inventory, asset) - .find((property) => property.propertyid === 6) - ?.string_value?.trim(); + const inspect = getSkinCraftInspect(asset, inventory.m_rgAssetProperties[asset.assetid]); if (!inspect || !/^[0-9a-f]{40,8192}$/i.test(inspect)) return; const icon = asset.description.icon_url_large || asset.description.icon_url; + const itemInfo = getCachedItemInfo(asset.assetid); + const rarityColor = asset.description.tags?.find((tag) => tag.category === 'Rarity')?.color; + const backgroundColor = asset.description.background_color; return { inspect, name: asset.description.market_hash_name, iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, assetId: asset.assetid, + seed: itemInfo ? formatSeed(itemInfo) : undefined, + float: itemInfo ? formatFloatWithRank(itemInfo, 6) : undefined, + rarityColor: rarityColor && /^[0-9a-f]{6}$/i.test(rarityColor) ? rarityColor : undefined, + backgroundColor: backgroundColor && /^[0-9a-f]{6}$/i.test(backgroundColor) ? backgroundColor : undefined, }; } export function getLoadedInventoryTargets( - activeInventory: CInventory | CAppwideInventory + activeInventory: CInventory | CAppwideInventory, + getCachedItemInfo: (assetId: string) => ItemInfo | undefined = (assetId) => gFloatFetcher.getCached(assetId) ): OpenSkinCraftViewerTarget[] { const inventories = isCAppwideInventory(activeInventory) ? [ @@ -47,7 +71,7 @@ export function getLoadedInventoryTargets( for (const asset of Object.values(inventory.m_rgAssets)) { if (seenAssets.has(asset.assetid)) continue; - const target = toViewerTarget(inventory, asset); + const target = toViewerTarget(inventory, asset, getCachedItemInfo); if (!target) continue; seenAssets.add(asset.assetid); diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts index de422269..0c0bdf4b 100644 --- a/src/lib/services/skincraft_viewer_protocol.test.ts +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -11,6 +11,10 @@ const target: OpenSkinCraftViewerTarget = { name: 'AK-47 | Redline', iconUrl: 'https://community.akamai.steamstatic.com/economy/image/example/330x192', assetId: '12345678901234567890', + seed: '977', + float: '0.295375', + rarityColor: '8847ff', + backgroundColor: '20242d', }; describe('SkinCraft viewer open messages', () => { @@ -42,6 +46,14 @@ describe('SkinCraft viewer open messages', () => { inventory: [{...target, iconUrl: 'https://example.com/item.png'}], }) ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [{...target, rarityColor: 'not-a-color'}], + }) + ).toBe(false); }); it('rejects oversized inventory snapshots', () => { diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index 507803c6..30059d1a 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -6,6 +6,10 @@ export type OpenSkinCraftViewerTarget = { name: string; iconUrl?: string; assetId?: string; + seed?: string; + float?: string; + rarityColor?: string; + backgroundColor?: string; }; export type OpenSkinCraftViewerMessage = { @@ -25,6 +29,12 @@ function isViewerTarget(data: unknown): data is OpenSkinCraftViewerTarget { typeof target.name === 'string' && target.name.length <= 512 && (target.assetId === undefined || (typeof target.assetId === 'string' && /^\d{1,32}$/.test(target.assetId))) && + (target.seed === undefined || (typeof target.seed === 'string' && target.seed.length <= 64)) && + (target.float === undefined || (typeof target.float === 'string' && target.float.length <= 64)) && + (target.rarityColor === undefined || + (typeof target.rarityColor === 'string' && /^[0-9a-f]{6}$/i.test(target.rarityColor))) && + (target.backgroundColor === undefined || + (typeof target.backgroundColor === 'string' && /^[0-9a-f]{6}$/i.test(target.backgroundColor))) && (target.iconUrl === undefined || (typeof target.iconUrl === 'string' && target.iconUrl.length <= 4096 && diff --git a/src/lib/types/steam.d.ts b/src/lib/types/steam.d.ts index 09457c2a..4a402c81 100644 --- a/src/lib/types/steam.d.ts +++ b/src/lib/types/steam.d.ts @@ -64,6 +64,7 @@ export interface rgDescription { type: string; tags?: { category: string; + color?: string; name?: string; internal_name: string; localized_category_name?: string; From 4c4e0619da79fb2d3c791045cba1ba5002dbe136 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 27 Jul 2026 18:47:20 -0400 Subject: [PATCH 07/17] refactor(inventory): align SkinCraft embed with house conventions Applies the review conventions from phoenix#1669 to the extension embed. - drop the export-for-test inventory-click helper and its spec - collapse the duplicate frame-reveal methods into showFrame() - share one target builder, one Steam image helper, and named inspect/hex-colour patterns instead of repeated literals - move the shared target types beside the protocol so the service no longer imports a type from a component - split the modal CSS into skincraft_viewer_modal_styles.ts - fold isSellableOnCSFloat into canListOnCSFloat and derive the brand icon from environment.skincraft_embed_origin - point the dev embed origin at localhost --- src/environment.dev.ts | 2 +- .../inventory/selected_item_info.ts | 36 +- .../inventory/skincraft_viewer_modal.ts | 522 +----------------- .../skincraft_viewer_modal_styles.ts | 487 ++++++++++++++++ src/lib/services/skincraft_embed.ts | 25 +- .../skincraft_inventory_targets.test.ts | 6 +- .../services/skincraft_inventory_targets.ts | 47 +- .../skincraft_viewer_protocol.test.ts | 4 +- src/lib/services/skincraft_viewer_protocol.ts | 31 +- src/lib/utils/steam_images.ts | 5 + 10 files changed, 587 insertions(+), 578 deletions(-) create mode 100644 src/lib/components/inventory/skincraft_viewer_modal_styles.ts create mode 100644 src/lib/utils/steam_images.ts diff --git a/src/environment.dev.ts b/src/environment.dev.ts index c001bb7e..1db4c7c5 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -7,5 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'http://localhost:3434/api', floatdb_gateway_url: 'https://gateway.floatdb.com', - skincraft_embed_origin: 'https://192.168.8.131:3000', + skincraft_embed_origin: 'https://localhost:3000', }; diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index fa76ed48..fc977302 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -23,8 +23,10 @@ import {Contract} from '../../types/float_market'; import '../common/ui/floatbar'; import {ClientSend} from '../../bridge/client'; import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem'; +import {environment} from '../../../environment'; import {gSkinCraftEmbed} from '../../services/skincraft_embed'; -import {getSkinCraftInspect} from '../../services/skincraft_inventory_targets'; +import {toSkinCraftItem} from '../../services/skincraft_inventory_targets'; +import type {SkinCraftItem} from '../../services/skincraft_viewer_protocol'; import './list_item_modal'; /** @@ -89,7 +91,9 @@ export class SelectedItemInfo extends FloatElement { cursor: pointer; font-family: inherit; text-decoration: none; - transition: all 180ms ease; + transition: + filter 180ms ease, + transform 180ms ease; box-shadow: 0 4px 12px hsl(0 0% 0% / 0.22), 0 1px 2px hsl(0 0% 0% / 0.16), @@ -195,14 +199,13 @@ export class SelectedItemInfo extends FloatElement { ); } - const isMarketItem = isSellableOnCSFloat(this.asset.description); - if ((isMarketItem && this.canListOnCSFloat) || this.canViewInSkinCraft) { + if (this.canListOnCSFloat || this.canViewInSkinCraft) { containerChildren.push( html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
` ); } - if (isMarketItem) { + if (isSellableOnCSFloat(this.asset.description)) { containerChildren.push(this.renderFloatMarketListing()); } @@ -269,18 +272,20 @@ export class SelectedItemInfo extends FloatElement { private get canListOnCSFloat(): boolean { return ( + !!this.asset?.description && + isSellableOnCSFloat(this.asset.description) && !this.stallListing && g_ActiveInventory?.m_owner?.strSteamId === g_steamID && - !!this.asset?.description?.tradable + !!this.asset.description.tradable ); } - private get skinCraftInspect(): string | undefined { - return getSkinCraftInspect(this.asset); + private get skinCraftItem(): SkinCraftItem | undefined { + return toSkinCraftItem(this.asset); } private get canViewInSkinCraft(): boolean { - return !!this.skinCraftInspect; + return !!this.skinCraftItem; } renderViewIn3D(): TemplateResult<1> { @@ -290,7 +295,7 @@ export class SelectedItemInfo extends FloatElement { return html` `; @@ -319,15 +324,8 @@ export class SelectedItemInfo extends FloatElement { } private handleViewIn3D(): void { - if (!this.asset || !this.skinCraftInspect) return; - - const icon = this.asset.description.icon_url_large || this.asset.description.icon_url; - gSkinCraftEmbed.open({ - inspect: this.skinCraftInspect, - name: this.asset.description.market_hash_name, - iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, - assetId: this.asset.assetid, - }); + const item = this.skinCraftItem; + if (item) gSkinCraftEmbed.open(item); } async processSelectChange() { diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index 71d8505d..9d9a9edb 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -1,501 +1,5 @@ -export type SkinCraftViewerTarget = { - inspect: string; - name: string; - iconUrl?: string; - itemUrl: string; - assetId?: string; - seed?: string; - float?: string; - rarityColor?: string; - backgroundColor?: string; -}; - -const MODAL_TRANSITION_MS = 200; - -const MODAL_STYLES = ` - :host { - color: #f5f8ff; - font-family: Arial, Helvetica, sans-serif; - } - - dialog { - width: min(1120px, calc(100vw - 48px)); - max-width: none; - padding: 0; - border: 1px solid rgba(193, 206, 255, 0.12); - border-radius: 12px; - overflow: hidden; - color: inherit; - background: #15171c; - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); - opacity: 1; - transform: translateY(0) scale(1); - transform-origin: center; - transition: - opacity 180ms ease, - transform 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - - dialog::backdrop { - background: rgba(5, 7, 12, 0.78); - backdrop-filter: blur(6px); - opacity: 1; - transition: - opacity 180ms ease, - backdrop-filter 200ms ease; - } - - dialog.entering, - dialog.closing { - opacity: 0; - transform: translateY(10px) scale(0.97); - } - - dialog.entering::backdrop, - dialog.closing::backdrop { - backdrop-filter: blur(0); - opacity: 0; - } - - dialog.closing { - pointer-events: none; - } - - .modal-body { - min-width: 0; - } - - .modal-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - height: 52px; - padding: 0 12px 0 18px; - border-bottom: 1px solid rgba(193, 206, 255, 0.1); - background: #1b1e25; - } - - .modal-title { - min-width: 0; - overflow: hidden; - font-size: 15px; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; - } - - .modal-brand { - color: rgba(245, 248, 255, 0.58); - font-weight: 400; - } - - .close-button { - display: grid; - flex: 0 0 auto; - width: 34px; - height: 34px; - padding: 0; - place-items: center; - color: rgba(245, 248, 255, 0.72); - font: inherit; - font-size: 22px; - line-height: 1; - background: transparent; - border: 0; - border-radius: 8px; - cursor: pointer; - transition: color 150ms ease, background-color 150ms ease; - } - - .close-button:hover { - color: #fff; - background: rgba(255, 255, 255, 0.09); - } - - .inventory-panel { - display: none; - min-width: 0; - min-height: 0; - background: #181b21; - } - - .inventory-panel-header { - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 0 14px; - color: rgba(245, 248, 255, 0.82); - font-size: 13px; - font-weight: 600; - } - - .inventory-count { - color: rgba(245, 248, 255, 0.42); - font-size: 11px; - font-variant-numeric: tabular-nums; - font-weight: 500; - } - - .inventory-grid { - min-width: 0; - min-height: 0; - scrollbar-color: rgba(193, 206, 255, 0.2) transparent; - scrollbar-width: thin; - } - - .inventory-grid::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - .inventory-grid::-webkit-scrollbar-thumb { - background: rgba(193, 206, 255, 0.2); - border: 2px solid transparent; - border-radius: 999px; - background-clip: padding-box; - } - - .inventory-card { - position: relative; - box-sizing: border-box; - min-width: 0; - padding: 5px; - overflow: hidden; - color: inherit; - background-color: var(--inventory-card-background, #2a2f3a); - border: 1px solid var(--inventory-card-rarity, rgba(193, 206, 255, 0.18)); - border-radius: 7px; - cursor: pointer; - transition: - background-color 140ms ease, - border-color 140ms ease, - box-shadow 140ms ease, - transform 100ms ease; - } - - .inventory-card:hover { - background-color: var(--inventory-card-hover, #363d4c); - } - - .inventory-card:focus-visible { - outline: 2px solid rgba(132, 136, 255, 0.95); - outline-offset: 1px; - } - - .inventory-card:active { - transform: scale(0.97); - } - - .inventory-card.selected { - background-color: var(--inventory-card-selected, #3e4186); - box-shadow: - 0 0 0 2px rgba(132, 136, 255, 0.92), - 0 6px 18px rgba(0, 0, 0, 0.28); - } - - .inventory-card img { - display: block; - width: 100%; - height: 100%; - object-fit: contain; - opacity: 0.78; - pointer-events: none; - transition: opacity 140ms ease; - } - - .inventory-card:hover img, - .inventory-card.selected img { - opacity: 1; - } - - .inventory-card-seed, - .inventory-card-float { - position: absolute; - right: 5px; - z-index: 1; - max-width: calc(100% - 10px); - overflow: hidden; - color: rgba(225, 230, 239, 0.66); - font-family: Arial, Helvetica, sans-serif; - font-size: 11px; - font-variant-numeric: tabular-nums; - line-height: 1; - text-overflow: ellipsis; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); - white-space: nowrap; - pointer-events: none; - } - - .inventory-card-seed { - top: 5px; - } - - .inventory-card-float { - bottom: 5px; - } - - .viewer-stage { - position: relative; - aspect-ratio: 16 / 9; - max-height: calc(100vh - 104px); - background: #111318; - } - - iframe { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - border: 0; - opacity: 0; - transition: opacity 280ms ease; - } - - iframe.loaded { - opacity: 1; - } - - .loading-cover { - position: absolute; - inset: 0; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 28px; - background: radial-gradient(circle at 50% 38%, rgba(81, 85, 235, 0.16), transparent 38%), #111318; - opacity: 1; - transition: opacity 280ms ease; - } - - .loading-cover.loaded { - opacity: 0; - pointer-events: none; - } - - .loading-status, - .error-status { - display: flex; - width: 100%; - height: 100%; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - } - - .item-icon { - width: 330px; - max-width: 58%; - max-height: 42%; - aspect-ratio: 330 / 192; - object-fit: contain; - filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.5)); - opacity: 1; - transition: opacity 160ms ease-out; - user-select: none; - } - - .item-icon.pending { - opacity: 0; - transition: none; - } - - .item-name { - max-width: 720px; - overflow: hidden; - color: rgba(245, 248, 255, 0.72); - font-size: 15px; - font-weight: 600; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - } - - .progress { - display: flex; - align-items: center; - gap: 10px; - width: min(320px, 64%); - min-height: 18px; - } - - .progress-track { - position: relative; - flex: 1; - height: 4px; - overflow: hidden; - border-radius: 999px; - background: rgba(255, 255, 255, 0.13); - } - - .progress-fill { - position: absolute; - inset-block: 0; - left: 0; - border-radius: inherit; - background: #5155eb; - box-shadow: 0 0 10px rgba(81, 85, 235, 0.58); - } - - .progress-fill.determinate { - width: 0; - } - - .progress-fill.indeterminate { - width: 38%; - animation: progress-sweep 1.15s ease-in-out infinite; - } - - .progress-value { - width: 4ch; - color: rgba(245, 248, 255, 0.56); - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - font-size: 12px; - font-variant-numeric: tabular-nums; - text-align: right; - } - - .error-message { - max-width: 560px; - color: #ff8585; - font-size: 14px; - line-height: 1.45; - text-align: center; - } - - .error-actions { - display: flex; - gap: 10px; - } - - .error-actions button, - .error-actions a { - padding: 9px 13px; - color: #f5f8ff; - font: inherit; - font-size: 13px; - text-decoration: none; - background: #5155eb; - border: 0; - border-radius: 7px; - cursor: pointer; - } - - .error-actions a { - background: rgba(255, 255, 255, 0.1); - } - - .hidden { - display: none !important; - } - - @keyframes progress-sweep { - from { transform: translateX(-120%); } - to { transform: translateX(320%); } - } - - @media (min-width: 1168px) and (max-width: 1519px) and (min-height: 840px) { - dialog.has-inventory { - width: 1120px; - } - - dialog.has-inventory .modal-body { - display: grid; - grid-template-rows: 122px auto; - } - - dialog.has-inventory .inventory-panel { - display: grid; - grid-template-columns: 122px minmax(0, 1fr); - border-bottom: 1px solid rgba(193, 206, 255, 0.1); - } - - dialog.has-inventory .inventory-panel-header { - border-right: 1px solid rgba(193, 206, 255, 0.08); - } - - dialog.has-inventory .inventory-grid { - display: flex; - gap: 8px; - padding: 11px; - overflow-x: auto; - overflow-y: hidden; - } - - dialog.has-inventory .inventory-card { - flex: 0 0 98px; - height: 98px; - } - } - - @media (min-width: 1520px) { - dialog.has-inventory { - width: min(1440px, calc(100vw - 48px)); - } - - dialog.has-inventory .modal-body { - display: grid; - grid-template-columns: minmax(280px, 320px) 1120px; - } - - dialog.has-inventory .inventory-panel { - display: flex; - flex-direction: column; - height: min(630px, calc(100vh - 104px)); - border-right: 1px solid rgba(193, 206, 255, 0.1); - } - - dialog.has-inventory .inventory-panel-header { - height: 48px; - border-bottom: 1px solid rgba(193, 206, 255, 0.08); - } - - dialog.has-inventory .inventory-grid { - display: grid; - flex: 1; - grid-template-columns: repeat(3, minmax(0, 1fr)); - grid-auto-rows: 92px; - align-content: start; - gap: 8px; - padding: 10px; - overflow-x: hidden; - overflow-y: auto; - } - - dialog.has-inventory .inventory-card { - height: 92px; - } - - dialog.has-inventory .viewer-stage { - width: 1120px; - } - } - - @media (max-width: 640px) { - dialog { - width: calc(100vw - 20px); - } - - .modal-header { - height: 46px; - padding-left: 12px; - } - - .modal-brand { - display: none; - } - - .viewer-stage { - max-height: calc(100vh - 70px); - } - } -`; +import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles'; function createElement(tag: K, className?: string): HTMLElementTagNameMap[K] { const element = document.createElement(tag); @@ -547,7 +51,7 @@ export class SkinCraftViewerModal { ) { const shadow = this.element.attachShadow({mode: 'closed'}); const style = document.createElement('style'); - style.textContent = MODAL_STYLES; + style.textContent = skinCraftViewerModalStyles; shadow.appendChild(style); this.dialog.setAttribute('aria-labelledby', 'skincraft-viewer-title'); @@ -687,11 +191,12 @@ export class SkinCraftViewerModal { if (!this.dialog.open) { this.dialog.classList.add('entering'); this.dialog.showModal(); + // Two frames: `entering` has to be painted before it is removed, or the transition never runs. this.entryFrame = requestAnimationFrame(() => { this.entryFrame = requestAnimationFrame(() => { this.entryFrame = undefined; this.dialog.classList.remove('entering'); - this.iframe.focus({preventScroll: true}); + this.focusViewer(); }); }); } @@ -729,12 +234,8 @@ export class SkinCraftViewerModal { this.progressValue.classList.remove('hidden'); } - setLoaded(): void { - this.iframe.classList.add('loaded'); - this.loadingCover.classList.add('loaded'); - } - - continueLoadingInFrame(): void { + /** Reveals the embed and fades the loading cover out, whether the load just finished or is still running. */ + showFrame(): void { this.iframe.classList.add('loaded'); this.loadingCover.classList.add('loaded'); } @@ -768,7 +269,14 @@ export class SkinCraftViewerModal { if (!button || !Number.isInteger(index)) return; const inventoryTarget = this.inventoryTargets[index]; - if (inventoryTarget) this.onSelect(inventoryTarget); + if (!inventoryTarget) return; + + this.onSelect(inventoryTarget); + this.focusViewer(); + } + + private focusViewer(): void { + this.iframe.focus({preventScroll: true}); } private setInventoryCardColors(button: HTMLButtonElement, target: SkinCraftViewerTarget): void { diff --git a/src/lib/components/inventory/skincraft_viewer_modal_styles.ts b/src/lib/components/inventory/skincraft_viewer_modal_styles.ts new file mode 100644 index 00000000..68e18487 --- /dev/null +++ b/src/lib/components/inventory/skincraft_viewer_modal_styles.ts @@ -0,0 +1,487 @@ +/** Mirrors the longest `dialog` transition below; keep the two in sync. */ +export const MODAL_TRANSITION_MS = 200; + +export const skinCraftViewerModalStyles = ` + :host { + color: #f5f8ff; + font-family: Arial, Helvetica, sans-serif; + } + + dialog { + width: min(1120px, calc(100vw - 48px)); + max-width: none; + padding: 0; + border: 1px solid rgba(193, 206, 255, 0.12); + border-radius: 12px; + overflow: hidden; + color: inherit; + background: #15171c; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); + opacity: 1; + transform: translateY(0) scale(1); + transform-origin: center; + transition: + opacity 180ms ease, + transform 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + dialog::backdrop { + background: rgba(5, 7, 12, 0.78); + backdrop-filter: blur(6px); + opacity: 1; + transition: + opacity 180ms ease, + backdrop-filter 200ms ease; + } + + dialog.entering, + dialog.closing { + opacity: 0; + transform: translateY(10px) scale(0.97); + } + + dialog.entering::backdrop, + dialog.closing::backdrop { + backdrop-filter: blur(0); + opacity: 0; + } + + dialog.closing { + pointer-events: none; + } + + .modal-body { + min-width: 0; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 52px; + padding: 0 12px 0 18px; + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + background: #1b1e25; + } + + .modal-title { + min-width: 0; + overflow: hidden; + font-size: 15px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .modal-brand { + color: rgba(245, 248, 255, 0.58); + font-weight: 400; + } + + .close-button { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + color: rgba(245, 248, 255, 0.72); + font: inherit; + font-size: 22px; + line-height: 1; + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease; + } + + .close-button:hover { + color: #fff; + background: rgba(255, 255, 255, 0.09); + } + + .inventory-panel { + display: none; + min-width: 0; + min-height: 0; + background: #181b21; + } + + .inventory-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 14px; + color: rgba(245, 248, 255, 0.82); + font-size: 13px; + font-weight: 600; + } + + .inventory-count { + color: rgba(245, 248, 255, 0.42); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 500; + } + + .inventory-grid { + min-width: 0; + min-height: 0; + scrollbar-color: rgba(193, 206, 255, 0.2) transparent; + scrollbar-width: thin; + } + + .inventory-grid::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .inventory-grid::-webkit-scrollbar-thumb { + background: rgba(193, 206, 255, 0.2); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .inventory-card { + position: relative; + box-sizing: border-box; + min-width: 0; + padding: 5px; + overflow: hidden; + color: inherit; + background-color: var(--inventory-card-background, #2a2f3a); + border: 1px solid var(--inventory-card-rarity, rgba(193, 206, 255, 0.18)); + border-radius: 7px; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 100ms ease; + } + + .inventory-card:hover { + background-color: var(--inventory-card-hover, #363d4c); + } + + .inventory-card:focus-visible { + outline: 2px solid rgba(132, 136, 255, 0.95); + outline-offset: 1px; + } + + .inventory-card:active { + transform: scale(0.97); + } + + .inventory-card.selected { + background-color: var(--inventory-card-selected, #3e4186); + box-shadow: + 0 0 0 2px rgba(132, 136, 255, 0.92), + 0 6px 18px rgba(0, 0, 0, 0.28); + } + + .inventory-card img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0.78; + pointer-events: none; + transition: opacity 140ms ease; + } + + .inventory-card:hover img, + .inventory-card.selected img { + opacity: 1; + } + + .inventory-card-seed, + .inventory-card-float { + position: absolute; + right: 5px; + z-index: 1; + max-width: calc(100% - 10px); + overflow: hidden; + color: rgba(225, 230, 239, 0.66); + font-family: Arial, Helvetica, sans-serif; + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 1; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); + white-space: nowrap; + pointer-events: none; + } + + .inventory-card-seed { + top: 5px; + } + + .inventory-card-float { + bottom: 5px; + } + + .viewer-stage { + position: relative; + aspect-ratio: 16 / 9; + max-height: calc(100vh - 104px); + background: #111318; + } + + iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + opacity: 0; + transition: opacity 280ms ease; + } + + iframe.loaded { + opacity: 1; + } + + .loading-cover { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 28px; + background: radial-gradient(circle at 50% 38%, rgba(81, 85, 235, 0.16), transparent 38%), #111318; + opacity: 1; + transition: opacity 280ms ease; + } + + .loading-cover.loaded { + opacity: 0; + pointer-events: none; + } + + .loading-status, + .error-status { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + } + + .item-icon { + width: 330px; + max-width: 58%; + max-height: 42%; + aspect-ratio: 330 / 192; + object-fit: contain; + filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.5)); + opacity: 1; + transition: opacity 160ms ease-out; + user-select: none; + } + + .item-icon.pending { + opacity: 0; + transition: none; + } + + .item-name { + max-width: 720px; + overflow: hidden; + color: rgba(245, 248, 255, 0.72); + font-size: 15px; + font-weight: 600; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + + .progress { + display: flex; + align-items: center; + gap: 10px; + width: min(320px, 64%); + min-height: 18px; + } + + .progress-track { + position: relative; + flex: 1; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.13); + } + + .progress-fill { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background: #5155eb; + box-shadow: 0 0 10px rgba(81, 85, 235, 0.58); + } + + .progress-fill.determinate { + width: 0; + } + + .progress-fill.indeterminate { + width: 38%; + animation: progress-sweep 1.15s ease-in-out infinite; + } + + .progress-value { + width: 4ch; + color: rgba(245, 248, 255, 0.56); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; + } + + .error-message { + max-width: 560px; + color: #ff8585; + font-size: 14px; + line-height: 1.45; + text-align: center; + } + + .error-actions { + display: flex; + gap: 10px; + } + + .error-actions button, + .error-actions a { + padding: 9px 13px; + color: #f5f8ff; + font: inherit; + font-size: 13px; + text-decoration: none; + background: #5155eb; + border: 0; + border-radius: 7px; + cursor: pointer; + } + + .error-actions a { + background: rgba(255, 255, 255, 0.1); + } + + .hidden { + display: none !important; + } + + @keyframes progress-sweep { + from { transform: translateX(-120%); } + to { transform: translateX(320%); } + } + + @media (min-width: 1168px) and (max-width: 1519px) and (min-height: 840px) { + dialog.has-inventory { + width: 1120px; + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-rows: 122px auto; + } + + dialog.has-inventory .inventory-panel { + display: grid; + grid-template-columns: 122px minmax(0, 1fr); + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + border-right: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: flex; + gap: 8px; + padding: 11px; + overflow-x: auto; + overflow-y: hidden; + } + + dialog.has-inventory .inventory-card { + flex: 0 0 98px; + height: 98px; + } + } + + @media (min-width: 1520px) { + dialog.has-inventory { + width: min(1440px, calc(100vw - 48px)); + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-columns: minmax(280px, 320px) 1120px; + } + + dialog.has-inventory .inventory-panel { + display: flex; + flex-direction: column; + height: min(630px, calc(100vh - 104px)); + border-right: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + height: 48px; + border-bottom: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: grid; + flex: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: 92px; + align-content: start; + gap: 8px; + padding: 10px; + overflow-x: hidden; + overflow-y: auto; + } + + dialog.has-inventory .inventory-card { + height: 92px; + } + + dialog.has-inventory .viewer-stage { + width: 1120px; + } + } + + @media (max-width: 640px) { + dialog { + width: calc(100vw - 20px); + } + + .modal-header { + height: 46px; + padding-left: 12px; + } + + .modal-brand { + display: none; + } + + .viewer-stage { + max-height: calc(100vh - 70px); + } + } +`; diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 6617d45f..40bb7d7f 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -1,6 +1,5 @@ import {environment} from '../../environment'; import {SkinCraftViewerModal} from '../components/inventory/skincraft_viewer_modal'; -import type {SkinCraftViewerTarget} from '../components/inventory/skincraft_viewer_modal'; import {inPageContext} from '../utils/snips'; import { isSkinCraftEmbedEvent, @@ -11,7 +10,7 @@ import { import type {SkinCraftEmbedCommand} from './skincraft_embed_protocol'; import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; import {isOpenSkinCraftViewerMessage, SKINCRAFT_VIEWER_MESSAGE_SOURCE} from './skincraft_viewer_protocol'; -import type {OpenSkinCraftViewerMessage, OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; +import type {OpenSkinCraftViewerMessage, SkinCraftItem, SkinCraftViewerTarget} from './skincraft_viewer_protocol'; const LOAD_TIMEOUT_MS = 20_000; type LoadPhase = 'idle' | 'loading' | 'loaded' | 'error'; @@ -37,7 +36,7 @@ class SkinCraftEmbedService { if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); } - open(target: OpenSkinCraftViewerTarget): void { + open(target: SkinCraftItem): void { if (!target.inspect) return; if (this.runsInPage) { @@ -80,10 +79,10 @@ class SkinCraftEmbedService { this.openEmbeddedViewer(event.data.target, event.data.inventory); }; - private openEmbeddedViewer(target: OpenSkinCraftViewerTarget, inventory: OpenSkinCraftViewerTarget[]): void { + private openEmbeddedViewer(target: SkinCraftItem, inventory: SkinCraftItem[]): void { const modal = this.ensureModal(); - modal.setInventory(inventory.map((item) => this.toModalTarget(item))); - this.selectEmbeddedTarget(this.toModalTarget(target)); + modal.setInventory(inventory.map((item) => this.toViewerTarget(item))); + this.selectEmbeddedTarget(this.toViewerTarget(target)); } private selectEmbeddedTarget(target: SkinCraftViewerTarget): void { @@ -96,17 +95,11 @@ class SkinCraftEmbedService { document.addEventListener('visibilitychange', this.handleVisibilityChange); } - private toModalTarget(target: OpenSkinCraftViewerTarget): SkinCraftViewerTarget { + private toViewerTarget(target: SkinCraftItem): SkinCraftViewerTarget { return { - inspect: target.inspect, + ...target, name: target.name.trim() || 'SkinCraft 3D Viewer', - iconUrl: target.iconUrl, itemUrl: `${this.embedOrigin}/i/${encodeURIComponent(target.inspect)}`, - assetId: target.assetId, - seed: target.seed, - float: target.float, - rarityColor: target.rarityColor, - backgroundColor: target.backgroundColor, }; } @@ -136,7 +129,7 @@ class SkinCraftEmbedService { if (showLoadingCover) { this.modal?.setLoading(null); } else { - this.modal?.continueLoadingInFrame(); + this.modal?.showFrame(); } this.startLoadTimeout(); @@ -182,7 +175,7 @@ class SkinCraftEmbedService { this.clearLoadTimeout(); this.loadPhase = 'loaded'; this.showLoadingCover = false; - this.modal?.setLoaded(); + this.modal?.showFrame(); break; case 'error': if (!this.acceptsTerminalEvent(message.id)) return; diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts index c80817ac..1a12e3af 100644 --- a/src/lib/services/skincraft_inventory_targets.test.ts +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it} from 'vitest'; import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import type {CInventory, InventoryAsset} from '../types/steam'; -import {getLoadedInventoryTargets, getSkinCraftInspect} from './skincraft_inventory_targets'; +import {getLoadedInventoryTargets, toSkinCraftItem} from './skincraft_inventory_targets'; function createInventory(assets: InventoryAsset[]): CInventory { return { @@ -10,7 +10,7 @@ function createInventory(assets: InventoryAsset[]): CInventory { m_rgAssets: Object.fromEntries(assets.map((asset) => [asset.assetid, asset])), m_parentInventory: null, rgInventory: {}, - }; + } as unknown as CInventory; } describe('SkinCraft inventory targets', () => { @@ -25,7 +25,7 @@ describe('SkinCraft inventory targets', () => { }, } as unknown as InventoryAsset; - expect(getSkinCraftInspect(asset)).toBe('a'.repeat(80)); + expect(toSkinCraftItem(asset)?.inspect).toBe('a'.repeat(80)); }); it('skips Steam assets whose descriptions are not initialized yet', () => { diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 44309a49..a2f24d16 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -3,9 +3,18 @@ import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import {ContextId} from '../types/steam_constants'; import {isCAppwideInventory} from '../utils/checkers'; import {formatFloatWithRank, formatSeed, isSkin} from '../utils/skin'; +import {steamEconomyImageUrl} from '../utils/steam_images'; import {gFloatFetcher} from './float_fetcher'; -import {MAX_SKINCRAFT_INVENTORY_TARGETS} from './skincraft_viewer_protocol'; -import type {OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; +import { + HEX_COLOR_PATTERN, + MAX_SKINCRAFT_INVENTORY_TARGETS, + SKINCRAFT_INSPECT_PATTERN, +} from './skincraft_viewer_protocol'; +import type {SkinCraftItem} from './skincraft_viewer_protocol'; + +type CachedItemInfoLookup = (assetId: string) => ItemInfo | undefined; + +const cachedItemInfo: CachedItemInfoLookup = (assetId) => gFloatFetcher.getCached(assetId); function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetProperty[]): rgAssetProperty[] { if (asset.asset_properties?.length) return asset.asset_properties; @@ -13,9 +22,9 @@ function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetPr return fallbackProperties; } -export function getSkinCraftInspect( +function getSkinCraftInspect( asset: InventoryAsset | undefined, - fallbackProperties: rgAssetProperty[] = [] + fallbackProperties: rgAssetProperty[] ): string | undefined { if (!asset?.description || !isSkin(asset.description)) return; @@ -26,15 +35,15 @@ export function getSkinCraftInspect( ); } -function toViewerTarget( - inventory: CInventory, - asset: InventoryAsset, - getCachedItemInfo: (assetId: string) => ItemInfo | undefined -): OpenSkinCraftViewerTarget | undefined { - if (!asset.description || typeof asset.description.market_hash_name !== 'string') return; +export function toSkinCraftItem( + asset: InventoryAsset | undefined, + fallbackProperties: rgAssetProperty[] = [], + getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo +): SkinCraftItem | undefined { + if (!asset?.description || typeof asset.description.market_hash_name !== 'string') return; - const inspect = getSkinCraftInspect(asset, inventory.m_rgAssetProperties[asset.assetid]); - if (!inspect || !/^[0-9a-f]{40,8192}$/i.test(inspect)) return; + const inspect = getSkinCraftInspect(asset, fallbackProperties); + if (!inspect || !SKINCRAFT_INSPECT_PATTERN.test(inspect)) return; const icon = asset.description.icon_url_large || asset.description.icon_url; const itemInfo = getCachedItemInfo(asset.assetid); @@ -43,26 +52,26 @@ function toViewerTarget( return { inspect, name: asset.description.market_hash_name, - iconUrl: icon ? `https://community.akamai.steamstatic.com/economy/image/${icon}/330x192` : undefined, + iconUrl: icon ? steamEconomyImageUrl(icon) : undefined, assetId: asset.assetid, seed: itemInfo ? formatSeed(itemInfo) : undefined, float: itemInfo ? formatFloatWithRank(itemInfo, 6) : undefined, - rarityColor: rarityColor && /^[0-9a-f]{6}$/i.test(rarityColor) ? rarityColor : undefined, - backgroundColor: backgroundColor && /^[0-9a-f]{6}$/i.test(backgroundColor) ? backgroundColor : undefined, + rarityColor: rarityColor && HEX_COLOR_PATTERN.test(rarityColor) ? rarityColor : undefined, + backgroundColor: backgroundColor && HEX_COLOR_PATTERN.test(backgroundColor) ? backgroundColor : undefined, }; } export function getLoadedInventoryTargets( activeInventory: CInventory | CAppwideInventory, - getCachedItemInfo: (assetId: string) => ItemInfo | undefined = (assetId) => gFloatFetcher.getCached(assetId) -): OpenSkinCraftViewerTarget[] { + getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo +): SkinCraftItem[] { const inventories = isCAppwideInventory(activeInventory) ? [ activeInventory.m_rgChildInventories[ContextId.PRIMARY], activeInventory.m_rgChildInventories[ContextId.PROTECTED], ] : [activeInventory]; - const targets: OpenSkinCraftViewerTarget[] = []; + const targets: SkinCraftItem[] = []; const seenAssets = new Set(); for (const inventory of inventories) { @@ -71,7 +80,7 @@ export function getLoadedInventoryTargets( for (const asset of Object.values(inventory.m_rgAssets)) { if (seenAssets.has(asset.assetid)) continue; - const target = toViewerTarget(inventory, asset, getCachedItemInfo); + const target = toSkinCraftItem(asset, inventory.m_rgAssetProperties[asset.assetid], getCachedItemInfo); if (!target) continue; seenAssets.add(asset.assetid); diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts index 0c0bdf4b..85cb75a4 100644 --- a/src/lib/services/skincraft_viewer_protocol.test.ts +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -4,9 +4,9 @@ import { MAX_SKINCRAFT_INVENTORY_TARGETS, SKINCRAFT_VIEWER_MESSAGE_SOURCE, } from './skincraft_viewer_protocol'; -import type {OpenSkinCraftViewerTarget} from './skincraft_viewer_protocol'; +import type {SkinCraftItem} from './skincraft_viewer_protocol'; -const target: OpenSkinCraftViewerTarget = { +const target: SkinCraftItem = { inspect: 'a'.repeat(80), name: 'AK-47 | Redline', iconUrl: 'https://community.akamai.steamstatic.com/economy/image/example/330x192', diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index 30059d1a..eea77f02 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -1,7 +1,13 @@ +import {STEAM_ECONOMY_IMAGE_PREFIX} from '../utils/steam_images'; + export const SKINCRAFT_VIEWER_MESSAGE_SOURCE = 'csfloat-skincraft-viewer' as const; export const MAX_SKINCRAFT_INVENTORY_TARGETS = 2_000; -export type OpenSkinCraftViewerTarget = { +export const SKINCRAFT_INSPECT_PATTERN = /^[0-9a-f]{40,8192}$/i; +export const HEX_COLOR_PATTERN = /^[0-9a-f]{6}$/i; + +/** An item as it crosses the page → content-script boundary. */ +export type SkinCraftItem = { inspect: string; name: string; iconUrl?: string; @@ -12,33 +18,36 @@ export type OpenSkinCraftViewerTarget = { backgroundColor?: string; }; +/** A {@link SkinCraftItem} the modal can render, with its SkinCraft permalink resolved. */ +export type SkinCraftViewerTarget = SkinCraftItem & {itemUrl: string}; + export type OpenSkinCraftViewerMessage = { source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; type: 'open'; - target: OpenSkinCraftViewerTarget; - inventory: OpenSkinCraftViewerTarget[]; + target: SkinCraftItem; + inventory: SkinCraftItem[]; }; -function isViewerTarget(data: unknown): data is OpenSkinCraftViewerTarget { +function isSkinCraftItem(data: unknown): data is SkinCraftItem { if (!data || typeof data !== 'object') return false; - const target = data as Partial; + const target = data as Partial; return ( typeof target.inspect === 'string' && - /^[0-9a-f]{40,8192}$/i.test(target.inspect) && + SKINCRAFT_INSPECT_PATTERN.test(target.inspect) && typeof target.name === 'string' && target.name.length <= 512 && (target.assetId === undefined || (typeof target.assetId === 'string' && /^\d{1,32}$/.test(target.assetId))) && (target.seed === undefined || (typeof target.seed === 'string' && target.seed.length <= 64)) && (target.float === undefined || (typeof target.float === 'string' && target.float.length <= 64)) && (target.rarityColor === undefined || - (typeof target.rarityColor === 'string' && /^[0-9a-f]{6}$/i.test(target.rarityColor))) && + (typeof target.rarityColor === 'string' && HEX_COLOR_PATTERN.test(target.rarityColor))) && (target.backgroundColor === undefined || - (typeof target.backgroundColor === 'string' && /^[0-9a-f]{6}$/i.test(target.backgroundColor))) && + (typeof target.backgroundColor === 'string' && HEX_COLOR_PATTERN.test(target.backgroundColor))) && (target.iconUrl === undefined || (typeof target.iconUrl === 'string' && target.iconUrl.length <= 4096 && - target.iconUrl.startsWith('https://community.akamai.steamstatic.com/economy/image/'))) + target.iconUrl.startsWith(STEAM_ECONOMY_IMAGE_PREFIX))) ); } @@ -49,9 +58,9 @@ export function isOpenSkinCraftViewerMessage(data: unknown): data is OpenSkinCra return ( message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && message.type === 'open' && - isViewerTarget(message.target) && + isSkinCraftItem(message.target) && Array.isArray(message.inventory) && message.inventory.length <= MAX_SKINCRAFT_INVENTORY_TARGETS && - message.inventory.every(isViewerTarget) + message.inventory.every(isSkinCraftItem) ); } diff --git a/src/lib/utils/steam_images.ts b/src/lib/utils/steam_images.ts new file mode 100644 index 00000000..53fc935e --- /dev/null +++ b/src/lib/utils/steam_images.ts @@ -0,0 +1,5 @@ +export const STEAM_ECONOMY_IMAGE_PREFIX = 'https://community.akamai.steamstatic.com/economy/image/'; + +export function steamEconomyImageUrl(icon: string, size = '330x192'): string { + return `${STEAM_ECONOMY_IMAGE_PREFIX}${icon}/${size}`; +} From 6f89fc42140c34850b138dc2113c5c99a365c7ce Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 02:30:37 -0400 Subject: [PATCH 08/17] refactor(inventory): render the SkinCraft viewer modal with lit-html Replaces the createElement tree and the manual classList toggling with a lit-html template, keeping the imperative shell the modal needs. A FloatElement is not an option here: the content script builds this modal and Chrome leaves `customElements` null in isolated worlds, so no custom element can be defined in that context. `render()` only needs the DOM. - classMap/styleMap replace hand-built class strings and style.setProperty - createRef/ref replace the stored dialog and iframe handles - guard() around the inventory grid so progress ticks don't re-diff every card - both status blocks stay mounted and toggle `hidden`, so the item icon survives an error -> retry cycle (its fade-in needs the to already exist) The iframe stays unconditional with a static src, so it is created once and only diffed after that, and the embed still boots exactly once. The service's view of the modal is unchanged. --- .../inventory/skincraft_viewer_modal.ts | 533 +++++++++--------- 1 file changed, 279 insertions(+), 254 deletions(-) diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index 9d9a9edb..53cb5201 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -1,11 +1,13 @@ +import {html, nothing, render} from 'lit'; +import type {TemplateResult} from 'lit'; +import {classMap} from 'lit/directives/class-map.js'; +import {guard} from 'lit/directives/guard.js'; +import {createRef, ref} from 'lit/directives/ref.js'; +import {styleMap} from 'lit/directives/style-map.js'; import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles'; -function createElement(tag: K, className?: string): HTMLElementTagNameMap[K] { - const element = document.createElement(tag); - if (className) element.className = className; - return element; -} +type LoadPhase = 'loading' | 'revealed' | 'error'; function mixHexColors(base: string, tint: string, tintAmount: number): string { const mixChannel = (offset: number): number => { @@ -17,319 +19,339 @@ function mixHexColors(base: string, tint: string, tintAmount: number): string { return `rgb(${mixChannel(0)} ${mixChannel(2)} ${mixChannel(4)})`; } +function inventoryCardColors(target: SkinCraftViewerTarget): Record { + const base = target.backgroundColor || '2a2f3a'; + const rarity = target.rarityColor || 'c1ceff'; + + return { + '--inventory-card-background': target.backgroundColor ? `#${base}` : mixHexColors(base, rarity, 0.1), + '--inventory-card-rarity': `#${rarity}`, + '--inventory-card-hover': mixHexColors(base, rarity, 0.16), + '--inventory-card-selected': mixHexColors(base, rarity, 0.26), + }; +} + +function targetKey(target: SkinCraftViewerTarget): string { + return target.assetId || target.inspect; +} + +/** + * Renders with lit-html instead of extending `FloatElement`: the content script builds this modal, + * and Chrome leaves `customElements` null in isolated worlds, so no custom element can be defined + * here. `render()` needs nothing but the DOM, and the embed iframe is created once and then only + * ever diffed, so it is never re-`src`'d. + */ export class SkinCraftViewerModal { readonly element = document.createElement('div'); - private readonly dialog = document.createElement('dialog'); - private readonly title = createElement('span'); - private readonly iframe = document.createElement('iframe'); - private readonly loadingCover = createElement('div', 'loading-cover'); - private readonly loadingStatus = createElement('div', 'loading-status'); - private readonly itemIcon = createElement('img', 'item-icon'); - private readonly itemName = createElement('div', 'item-name'); - private readonly indeterminateProgress = createElement('div', 'progress-fill indeterminate'); - private readonly determinateProgress = createElement('div', 'progress-fill determinate hidden'); - private readonly progressValue = createElement('span', 'progress-value hidden'); - private readonly progress = createElement('div', 'progress'); - private readonly errorStatus = createElement('div', 'error-status hidden'); - private readonly errorMessage = createElement('div', 'error-message'); - private readonly itemLink = createElement('a'); - private readonly inventoryCount = createElement('span', 'inventory-count'); - private readonly inventoryGrid = createElement('div', 'inventory-grid'); - private readonly inventoryButtons = new Map(); + private readonly root: ShadowRoot; + private readonly dialogRef = createRef(); + private readonly frameRef = createRef(); + + private target?: SkinCraftViewerTarget; private inventoryTargets: SkinCraftViewerTarget[] = []; - private activeInventoryButton?: HTMLButtonElement; - private entryFrame?: number; + private phase: LoadPhase = 'loading'; + private progress: number | null = null; + private errorMessage = ''; + private entering = false; + private closing = false; + private iconReady = false; private closeTimer?: number; + private entryFrame?: number; private iconRequest = 0; constructor( - embedSrc: string, + private readonly embedSrc: string, private readonly onClose: () => void, private readonly onRetry: () => void, private readonly onSelect: (target: SkinCraftViewerTarget) => void ) { - const shadow = this.element.attachShadow({mode: 'closed'}); + // Closed so page scripts can't reach into the viewer we host on their document. + this.root = this.element.attachShadow({mode: 'closed'}); const style = document.createElement('style'); style.textContent = skinCraftViewerModalStyles; - shadow.appendChild(style); - - this.dialog.setAttribute('aria-labelledby', 'skincraft-viewer-title'); - this.dialog.addEventListener('cancel', (event) => { - event.preventDefault(); - this.onClose(); - }); - this.dialog.addEventListener('click', (event) => this.handleDialogClick(event)); - this.dialog.addEventListener('transitionend', (event) => { - if (event.target === this.dialog && event.propertyName === 'transform') this.finishClose(); - }); - - const header = createElement('header', 'modal-header'); - const titleContainer = createElement('div', 'modal-title'); - titleContainer.id = 'skincraft-viewer-title'; - const brand = createElement('span', 'modal-brand'); - brand.textContent = ' — SkinCraft 3D Viewer'; - titleContainer.append(this.title, brand); - - const closeButton = createElement('button', 'close-button'); - closeButton.type = 'button'; - closeButton.setAttribute('aria-label', 'Close 3D viewer'); - closeButton.textContent = '×'; - closeButton.addEventListener('click', this.onClose); - header.append(titleContainer, closeButton); - - const inventoryPanel = createElement('aside', 'inventory-panel'); - inventoryPanel.setAttribute('aria-label', 'Loaded inventory items'); - const inventoryHeader = createElement('div', 'inventory-panel-header'); - const inventoryLabel = createElement('span'); - inventoryLabel.textContent = 'Inventory'; - inventoryHeader.append(inventoryLabel, this.inventoryCount); - inventoryPanel.append(inventoryHeader, this.inventoryGrid); - this.inventoryGrid.addEventListener('click', (event) => this.handleInventoryClick(event)); - - const stage = createElement('div', 'viewer-stage'); - this.iframe.src = embedSrc; - this.iframe.title = 'SkinCraft 3D viewer'; - this.iframe.referrerPolicy = 'no-referrer'; - this.iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-downloads'); - this.iframe.setAttribute('allow', 'fullscreen'); - - this.itemIcon.alt = ''; - this.itemIcon.draggable = false; - this.loadingStatus.append(this.itemIcon, this.itemName, this.progress); - - this.progress.setAttribute('role', 'progressbar'); - this.progress.setAttribute('aria-label', 'Loading 3D viewer'); - this.progress.setAttribute('aria-valuemin', '0'); - this.progress.setAttribute('aria-valuemax', '100'); - const progressTrack = createElement('div', 'progress-track'); - progressTrack.append(this.indeterminateProgress, this.determinateProgress); - this.progress.append(progressTrack, this.progressValue); - - const errorActions = createElement('div', 'error-actions'); - const retryButton = createElement('button'); - retryButton.type = 'button'; - retryButton.textContent = 'Retry'; - retryButton.addEventListener('click', this.onRetry); - this.itemLink.target = '_blank'; - this.itemLink.rel = 'noopener noreferrer'; - this.itemLink.textContent = 'Open on SkinCraft'; - errorActions.append(retryButton, this.itemLink); - this.errorStatus.append(this.errorMessage, errorActions); - - this.loadingCover.append(this.loadingStatus, this.errorStatus); - stage.append(this.iframe, this.loadingCover); - const modalBody = createElement('div', 'modal-body'); - modalBody.append(inventoryPanel, stage); - this.dialog.append(header, modalBody); - shadow.appendChild(this.dialog); + this.root.appendChild(style); + this.update(); } get frameWindow(): Window | null { - return this.iframe.contentWindow; + return this.frameRef.value?.contentWindow ?? null; } get isOpen(): boolean { - return this.dialog.open; + return this.dialogRef.value?.open ?? false; } setInventory(targets: SkinCraftViewerTarget[]): void { this.inventoryTargets = targets; - this.inventoryButtons.clear(); - this.activeInventoryButton = undefined; - const fragment = document.createDocumentFragment(); - - for (const [index, target] of targets.entries()) { - const button = createElement('button', 'inventory-card'); - button.type = 'button'; - button.title = target.name; - button.dataset.index = String(index); - button.setAttribute('aria-label', `View ${target.name} in 3D`); - button.setAttribute('aria-pressed', 'false'); - this.setInventoryCardColors(button, target); - - if (target.iconUrl) { - const icon = createElement('img'); - icon.src = target.iconUrl; - icon.alt = ''; - icon.loading = 'lazy'; - icon.decoding = 'async'; - icon.draggable = false; - button.appendChild(icon); - } - - if (target.seed) { - const seed = createElement('span', 'inventory-card-seed'); - seed.textContent = target.seed; - button.appendChild(seed); - } - - if (target.float) { - const float = createElement('span', 'inventory-card-float'); - float.textContent = target.float; - button.appendChild(float); - } - - this.inventoryButtons.set(this.getTargetKey(target), button); - fragment.appendChild(button); - } - - this.inventoryGrid.replaceChildren(fragment); - this.inventoryCount.textContent = String(targets.length); - this.dialog.classList.toggle('has-inventory', targets.length > 1); + this.update(); } show(target: SkinCraftViewerTarget): void { - this.title.textContent = target.name; - this.itemName.textContent = target.name; - this.itemLink.href = target.itemUrl; - this.setItemIcon(target.iconUrl); - this.setActiveInventoryItem(target); + this.target = target; + this.iconReady = false; + const request = ++this.iconRequest; this.cancelClose(); + const opening = !this.isOpen; + if (opening) this.entering = true; + this.update(); - if (!this.dialog.open) { - this.dialog.classList.add('entering'); - this.dialog.showModal(); - // Two frames: `entering` has to be painted before it is removed, or the transition never runs. - this.entryFrame = requestAnimationFrame(() => { - this.entryFrame = requestAnimationFrame(() => { - this.entryFrame = undefined; - this.dialog.classList.remove('entering'); - this.focusViewer(); - }); - }); - } + void this.revealIconWhenDecoded(target.iconUrl, request); + this.scrollSelectedIntoView(); + if (opening) this.openDialog(); } hide(): void { - if (!this.dialog.open || this.dialog.classList.contains('closing')) return; + if (!this.isOpen || this.closing) return; this.cancelEntry(); - this.dialog.classList.remove('entering'); - this.dialog.classList.add('closing'); + this.entering = false; + this.closing = true; + this.update(); this.closeTimer = window.setTimeout(() => this.finishClose(), MODAL_TRANSITION_MS); } setLoading(value: number | null): void { - this.iframe.classList.remove('loaded'); - this.loadingCover.classList.remove('loaded'); - this.loadingStatus.classList.remove('hidden'); - this.errorStatus.classList.add('hidden'); - - if (value === null) { - this.progress.removeAttribute('aria-valuenow'); - this.indeterminateProgress.classList.remove('hidden'); - this.determinateProgress.classList.add('hidden'); - this.progressValue.classList.add('hidden'); - return; - } - - const displayValue = Math.round(value); - this.progress.setAttribute('aria-valuenow', String(displayValue)); - this.indeterminateProgress.classList.add('hidden'); - this.determinateProgress.classList.remove('hidden'); - this.determinateProgress.style.width = `${value}%`; - this.progressValue.textContent = `${displayValue}%`; - this.progressValue.classList.remove('hidden'); + this.phase = 'loading'; + this.progress = value; + this.update(); } /** Reveals the embed and fades the loading cover out, whether the load just finished or is still running. */ showFrame(): void { - this.iframe.classList.add('loaded'); - this.loadingCover.classList.add('loaded'); + this.phase = 'revealed'; + this.update(); } setError(message: string): void { - this.iframe.classList.remove('loaded'); - this.loadingCover.classList.remove('loaded'); - this.loadingStatus.classList.add('hidden'); - this.errorStatus.classList.remove('hidden'); - this.errorMessage.textContent = message; + this.phase = 'error'; + this.errorMessage = message; + this.update(); } - private handleDialogClick(event: MouseEvent): void { - if (event.target !== this.dialog) return; + // `host` binds `this` inside the template's @event handlers, the way LitElement does it. + private update(): void { + render(this.template(), this.root, {host: this}); + } - const rect = this.dialog.getBoundingClientRect(); - const inside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - if (!inside) this.onClose(); + private template(): TemplateResult { + const revealed = this.phase !== 'loading'; + + return html` + + + + + `; } - private handleInventoryClick(event: MouseEvent): void { - const target = event.target; - if (!(target instanceof Element)) return; + private get selectedKey(): string | undefined { + return this.target ? targetKey(this.target) : undefined; + } - const button = target.closest('.inventory-card'); - const index = Number(button?.dataset.index); - if (!button || !Number.isInteger(index)) return; + // Both status blocks stay mounted and toggle `hidden` so the item icon survives an error → + // retry cycle; its fade-in is driven by a decode() that needs the to already exist. + private renderLoading(): TemplateResult { + const determinate = this.progress !== null; + const percent = Math.round(this.progress ?? 0); + + return html` +
+ ${this.target?.iconUrl + ? html`` + : nothing} +
${this.target?.name ?? ''}
+
+
+
+
+
+ ${determinate ? `${percent}%` : ''} +
+
+ `; + } - const inventoryTarget = this.inventoryTargets[index]; - if (!inventoryTarget) return; + private renderError(): TemplateResult { + return html` +
+
${this.errorMessage}
+ +
+ `; + } - this.onSelect(inventoryTarget); - this.focusViewer(); + private renderInventoryCards(): TemplateResult[] { + const selectedKey = this.selectedKey; + + return this.inventoryTargets.map((target, index) => { + const selected = targetKey(target) === selectedKey; + + return html` + + `; + }); } - private focusViewer(): void { - this.iframe.focus({preventScroll: true}); + private openDialog(): void { + const dialog = this.dialogRef.value; + if (!dialog || dialog.open) return; + + dialog.showModal(); + // Two frames: `entering` has to be painted before it is removed, or the transition never runs. + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = undefined; + this.entering = false; + this.update(); + this.focusViewer(); + }); + }); } - private setInventoryCardColors(button: HTMLButtonElement, target: SkinCraftViewerTarget): void { - const base = target.backgroundColor || '2a2f3a'; - const rarity = target.rarityColor || 'c1ceff'; - const background = target.backgroundColor ? `#${base}` : mixHexColors(base, rarity, 0.1); - button.style.setProperty('--inventory-card-background', background); - button.style.setProperty('--inventory-card-rarity', `#${rarity}`); - button.style.setProperty('--inventory-card-hover', mixHexColors(base, rarity, 0.16)); - button.style.setProperty('--inventory-card-selected', mixHexColors(base, rarity, 0.26)); + private async revealIconWhenDecoded(iconUrl: string | undefined, request: number): Promise { + if (!iconUrl) return; + + const icon = this.root.querySelector('.item-icon'); + if (!icon || icon.src !== iconUrl) return; + + const decoded = await icon.decode().then( + () => true, + () => false + ); + if (!decoded || request !== this.iconRequest) return; + + requestAnimationFrame(() => { + if (request !== this.iconRequest) return; + this.iconReady = true; + this.update(); + }); } - private setItemIcon(iconUrl?: string): void { - const request = ++this.iconRequest; - this.itemIcon.classList.add('pending'); + private scrollSelectedIntoView(): void { + const card = this.root.querySelector('.inventory-card.selected'); + if (card) requestAnimationFrame(() => card.scrollIntoView({block: 'nearest', inline: 'nearest'})); + } - if (!iconUrl) { - this.itemIcon.removeAttribute('src'); - this.itemIcon.classList.add('hidden'); - return; - } + private focusViewer(): void { + this.frameRef.value?.focus({preventScroll: true}); + } - this.itemIcon.classList.remove('hidden'); - this.itemIcon.src = iconUrl; - void this.itemIcon - .decode() - .then(() => { - if (request !== this.iconRequest || this.itemIcon.src !== iconUrl) return; - requestAnimationFrame(() => { - if (request === this.iconRequest) this.itemIcon.classList.remove('pending'); - }); - }) - .catch(() => undefined); + private handleCancel(event: Event): void { + event.preventDefault(); + this.onClose(); } - private getTargetKey(target: SkinCraftViewerTarget): string { - return target.assetId || target.inspect; + private handleDialogClick(event: MouseEvent): void { + const dialog = this.dialogRef.value; + if (!dialog || event.target !== dialog) return; + + const rect = dialog.getBoundingClientRect(); + const inside = + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom; + if (!inside) this.onClose(); } - private setActiveInventoryItem(target: SkinCraftViewerTarget): void { - this.activeInventoryButton?.classList.remove('selected'); - this.activeInventoryButton?.setAttribute('aria-pressed', 'false'); + private handleTransitionEnd(event: TransitionEvent): void { + if (event.target === this.dialogRef.value && event.propertyName === 'transform') this.finishClose(); + } - const button = this.inventoryButtons.get(this.getTargetKey(target)); - if (!button) { - this.activeInventoryButton = undefined; - return; - } + private handleInventoryClick(event: MouseEvent): void { + const node = event.target; + if (!(node instanceof Element)) return; + + const button = node.closest('.inventory-card'); + const index = Number(button?.dataset.index); + if (!button || !Number.isInteger(index)) return; + + const target = this.inventoryTargets[index]; + if (!target) return; - button.classList.add('selected'); - button.setAttribute('aria-pressed', 'true'); - this.activeInventoryButton = button; - requestAnimationFrame(() => button.scrollIntoView({block: 'nearest', inline: 'nearest'})); + this.onSelect(target); + this.focusViewer(); } private cancelEntry(): void { @@ -343,14 +365,17 @@ export class SkinCraftViewerModal { window.clearTimeout(this.closeTimer); this.closeTimer = undefined; } - this.dialog.classList.remove('closing'); + this.closing = false; } private finishClose(): void { - if (!this.dialog.classList.contains('closing')) return; + if (!this.closing) return; this.cancelClose(); - this.dialog.classList.remove('entering'); - if (this.dialog.open) this.dialog.close(); + this.entering = false; + this.update(); + + const dialog = this.dialogRef.value; + if (dialog?.open) dialog.close(); } } From c44675b4ed8b8e39d06082b9161805764aab3a7b Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 16:15:36 -0400 Subject: [PATCH 09/17] feat(inventory): gate the 3D button on WebGPU support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches phoenix's WebGpuAvailabilityService: probe with requestAdapter() rather than trusting `navigator.gpu` to exist, since a present API still fails on outdated drivers or without hardware acceleration. The status is three-state and pessimistic while checking, so the button never appears and then fails. Clients with no `navigator.gpu` at all resolve to unavailable synchronously — that covers Firefox before 141, and the manifest supports 127+. Those users simply see no 3D button, rather than sitting through the 20s embed load timeout. Adapted to this codebase: a module singleton instead of a signals service, with the probe memoised behind settled() and started on first use so no GPU work happens at import time in the content-script world, where nothing renders. --- .../inventory/selected_item_info.ts | 14 ++++- src/lib/services/webgpu_availability.ts | 63 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/lib/services/webgpu_availability.ts diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index fc977302..8ec91425 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -27,6 +27,8 @@ import {environment} from '../../../environment'; import {gSkinCraftEmbed} from '../../services/skincraft_embed'; import {toSkinCraftItem} from '../../services/skincraft_inventory_targets'; import type {SkinCraftItem} from '../../services/skincraft_viewer_protocol'; +import {gWebGpuAvailability} from '../../services/webgpu_availability'; +import type {WebGpuAvailability} from '../../services/webgpu_availability'; import './list_item_modal'; /** @@ -127,6 +129,9 @@ export class SelectedItemInfo extends FloatElement { @state() private showListModal: boolean = false; + @state() + private webGpuStatus: WebGpuAvailability = gWebGpuAvailability.status; + private bluegemData: FetchBluegemResponse | undefined; get asset(): InventoryAsset | undefined { @@ -285,7 +290,7 @@ export class SelectedItemInfo extends FloatElement { } private get canViewInSkinCraft(): boolean { - return !!this.skinCraftItem; + return this.webGpuStatus === 'available' && !!this.skinCraftItem; } renderViewIn3D(): TemplateResult<1> { @@ -368,9 +373,16 @@ export class SelectedItemInfo extends FloatElement { this.loading = false; } + private async resolveWebGpuStatus(): Promise { + if (this.webGpuStatus === 'checking') this.webGpuStatus = await gWebGpuAvailability.settled(); + } + connectedCallback() { super.connectedCallback(); + // Settles once per session; the 3D button stays hidden until it does + void this.resolveWebGpuStatus(); + // For the initial load, in case an item is pre-selected this.processSelectChange(); diff --git a/src/lib/services/webgpu_availability.ts b/src/lib/services/webgpu_availability.ts new file mode 100644 index 00000000..97492102 --- /dev/null +++ b/src/lib/services/webgpu_availability.ts @@ -0,0 +1,63 @@ +export type WebGpuAvailability = 'checking' | 'available' | 'unavailable'; + +interface WebGpuApi { + requestAdapter(): Promise; +} + +interface WebGpuNavigator extends Navigator { + gpu?: WebGpuApi; +} + +/** + * Requesting an adapter verifies WebGPU actually works for this browser/GPU configuration — + * including hardware acceleration and driver support — without allocating a GPUDevice. A present + * `navigator.gpu` is not enough on its own. + */ +async function detectWebGpuAvailability(browserNavigator?: Pick): Promise { + const gpu = browserNavigator?.gpu; + if (!gpu) return false; + + try { + return (await gpu.requestAdapter()) != null; + } catch { + return false; + } +} + +/** + * Browser-level WebGPU capability check, gating every 3D affordance. + * + * `navigator.gpu` is not Baseline — absent in Firefox before 141 and platform-dependent after + * (https://developer.mozilla.org/en-US/docs/Web/API/Navigator/gpu#browser_compatibility), and the + * manifest supports Firefox 127+. Those clients see no 3D button at all, which is useful context + * for support tickets asking where it went. + */ +class WebGpuAvailabilityService { + private readonly browserNavigator = typeof navigator === 'undefined' ? undefined : (navigator as WebGpuNavigator); + + private state: WebGpuAvailability = this.browserNavigator?.gpu ? 'checking' : 'unavailable'; + private probe?: Promise; + + /** Pessimistic until the probe settles: 'checking' is not treated as available. */ + get status(): WebGpuAvailability { + return this.state; + } + + get available(): boolean { + return this.state === 'available'; + } + + /** Resolves once the probe settles, starting it on first call. */ + async settled(): Promise { + if (this.state === 'checking') { + this.probe ??= detectWebGpuAvailability(this.browserNavigator).then((available) => { + this.state = available ? 'available' : 'unavailable'; + }); + await this.probe; + } + + return this.state; + } +} + +export const gWebGpuAvailability = new WebGpuAvailabilityService(); From e19a47db8be971c10b5bceec1dd6cad80d4c6550 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:01:19 -0400 Subject: [PATCH 10/17] fix(inventory): keep SkinCraft error UI visible on load failure --- src/lib/components/inventory/skincraft_viewer_modal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index 53cb5201..8af3dc97 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -136,7 +136,7 @@ export class SkinCraftViewerModal { } private template(): TemplateResult { - const revealed = this.phase !== 'loading'; + const revealed = this.phase === 'revealed'; return html` Date: Tue, 28 Jul 2026 18:21:43 -0400 Subject: [PATCH 11/17] fix(inventory): harden SkinCraft embed load-state transitions --- src/lib/services/skincraft_embed.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 40bb7d7f..8e582339 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -31,6 +31,7 @@ class SkinCraftEmbedService { private loadProgress: number | null = null; private loadPhase: LoadPhase = 'idle'; private showLoadingCover = false; + private frameHasContent = false; constructor() { if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); @@ -67,6 +68,7 @@ class SkinCraftEmbedService { this.loadProgress = null; this.loadPhase = 'idle'; this.showLoadingCover = false; + this.frameHasContent = false; this.clearLoadTimeout(); this.post({type: 'clear'}); this.modal?.hide(); @@ -87,7 +89,9 @@ class SkinCraftEmbedService { private selectEmbeddedTarget(target: SkinCraftViewerTarget): void { const modal = this.ensureModal(); - const showLoadingCover = !modal.isOpen; + // Switch in place only when the frame is showing a model; otherwise dropping the cover + // would leave an empty or errored iframe on screen until the next terminal event. + const showLoadingCover = !modal.isOpen || !this.frameHasContent; this.active = true; this.activeTarget = target; modal.show(this.activeTarget); @@ -107,6 +111,7 @@ class SkinCraftEmbedService { if (this.modal?.element.isConnected) return this.modal; this.ready = false; + this.frameHasContent = false; const modal = new SkinCraftViewerModal( this.embedSrc, () => this.close(), @@ -175,6 +180,7 @@ class SkinCraftEmbedService { this.clearLoadTimeout(); this.loadPhase = 'loaded'; this.showLoadingCover = false; + this.frameHasContent = true; this.modal?.showFrame(); break; case 'error': @@ -217,6 +223,9 @@ class SkinCraftEmbedService { if (!this.active) return; this.loadPhase = 'error'; this.showLoadingCover = false; + // Drop the queued load so a late `ready` can't start it under the error UI — recovery + // goes through the Retry button. + this.pendingInspect = undefined; this.modal?.setError('The 3D viewer took too long to load.'); }, LOAD_TIMEOUT_MS); } From 43017d8c900954ae71c11acf61d29d318a9b4f80 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:21:43 -0400 Subject: [PATCH 12/17] fix(inventory): tolerate a missing Steam asset properties map --- src/lib/services/skincraft_inventory_targets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index a2f24d16..46c9c466 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -80,7 +80,7 @@ export function getLoadedInventoryTargets( for (const asset of Object.values(inventory.m_rgAssets)) { if (seenAssets.has(asset.assetid)) continue; - const target = toSkinCraftItem(asset, inventory.m_rgAssetProperties[asset.assetid], getCachedItemInfo); + const target = toSkinCraftItem(asset, inventory.m_rgAssetProperties?.[asset.assetid], getCachedItemInfo); if (!target) continue; seenAssets.add(asset.assetid); From 4d29f89371d7added8367b3373fadf417fa22687 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:34:53 -0400 Subject: [PATCH 13/17] fix(inventory): harden SkinCraft embed load lifecycle edge cases --- src/lib/services/skincraft_embed.ts | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 8e582339..1fbc22e7 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -76,7 +76,7 @@ class SkinCraftEmbedService { } private handleOpenRequest = (event: MessageEvent): void => { - if (event.origin !== window.location.origin) return; + if (event.source !== window || event.origin !== window.location.origin) return; if (!isOpenSkinCraftViewerMessage(event.data)) return; this.openEmbeddedViewer(event.data.target, event.data.inventory); }; @@ -160,11 +160,19 @@ class SkinCraftEmbedService { const message = event.data; switch (message.type) { case 'ready': { + // A repeat `ready` means the embed rebooted (crash or self-navigation) and lost + // its model — reload the current item behind the cover. + const rebooted = this.ready; this.ready = true; + if (rebooted) this.frameHasContent = false; if (this.active && this.pendingInspect) { const inspect = this.pendingInspect; this.pendingInspect = undefined; this.sendLoad(inspect); + } else if (rebooted && this.active && this.activeTarget) { + this.showLoadingCover = true; + this.modal?.setLoading(null); + this.sendLoad(this.activeTarget.inspect); } break; } @@ -184,10 +192,11 @@ class SkinCraftEmbedService { this.modal?.showFrame(); break; case 'error': - if (!this.acceptsTerminalEvent(message.id)) return; + if (!this.acceptsErrorEvent(message.id)) return; this.clearLoadTimeout(); this.loadPhase = 'error'; this.showLoadingCover = false; + this.frameHasContent = false; this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; } @@ -201,9 +210,21 @@ class SkinCraftEmbedService { return (this.loadPhase === 'loading' || this.loadPhase === 'error') && this.acceptsLoadEvent(id); } + /** Unlike `loaded`, an error can also invalidate a model that already loaded (e.g. a lost GPU device). */ + private acceptsErrorEvent(id?: string): boolean { + return this.loadPhase !== 'idle' && this.acceptsLoadEvent(id); + } + private handleVisibilityChange = (): void => { if (!this.active) return; - this.post({type: document.hidden ? 'pause' : 'resume'}); + // A paused embed emits no progress, so hold the load watchdog while the tab is hidden. + if (document.hidden) { + this.clearLoadTimeout(); + this.post({type: 'pause'}); + } else { + if (this.loadPhase === 'loading') this.startLoadTimeout(); + this.post({type: 'resume'}); + } }; private post(command: SkinCraftEmbedCommand): void { @@ -223,6 +244,7 @@ class SkinCraftEmbedService { if (!this.active) return; this.loadPhase = 'error'; this.showLoadingCover = false; + this.frameHasContent = false; // Drop the queued load so a late `ready` can't start it under the error UI — recovery // goes through the Retry button. this.pendingInspect = undefined; From dc4fc1fe19d624184b8921cab0ecebe326246180 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:34:53 -0400 Subject: [PATCH 14/17] fix(inventory): only close the SkinCraft modal on real backdrop clicks --- .../inventory/skincraft_viewer_modal.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts index 8af3dc97..f361196c 100644 --- a/src/lib/components/inventory/skincraft_viewer_modal.ts +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -56,6 +56,7 @@ export class SkinCraftViewerModal { private entering = false; private closing = false; private iconReady = false; + private backdropPressed = false; private closeTimer?: number; private entryFrame?: number; private iconRequest = 0; @@ -148,6 +149,7 @@ export class SkinCraftViewerModal { })}" aria-labelledby="skincraft-viewer-title" @cancel="${this.handleCancel}" + @pointerdown="${this.handleDialogPointerDown}" @click="${this.handleDialogClick}" @transitionend="${this.handleTransitionEnd}" > @@ -322,17 +324,29 @@ export class SkinCraftViewerModal { this.onClose(); } - private handleDialogClick(event: MouseEvent): void { + // A backdrop hit lands on the itself with coordinates outside its rect. + private isBackdropEvent(event: MouseEvent): boolean { const dialog = this.dialogRef.value; - if (!dialog || event.target !== dialog) return; + if (!dialog || event.target !== dialog) return false; const rect = dialog.getBoundingClientRect(); - const inside = - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom; - if (!inside) this.onClose(); + return ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ); + } + + private handleDialogPointerDown(event: PointerEvent): void { + this.backdropPressed = this.isBackdropEvent(event); + } + + private handleDialogClick(event: MouseEvent): void { + const pressed = this.backdropPressed; + this.backdropPressed = false; + // The press must start on the backdrop too, so a drag that merely ends there doesn't dismiss. + if (pressed && this.isBackdropEvent(event)) this.onClose(); } private handleTransitionEnd(event: TransitionEvent): void { From 3da4ca4f2cb5af0fe1c578299aa55b7b9395caac Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:34:53 -0400 Subject: [PATCH 15/17] fix(inventory): resolve selected-item properties from inventory maps --- .../inventory/selected_item_info.ts | 4 ++-- .../services/skincraft_inventory_targets.ts | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index 8ec91425..8fde88f9 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -25,7 +25,7 @@ import {ClientSend} from '../../bridge/client'; import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem'; import {environment} from '../../../environment'; import {gSkinCraftEmbed} from '../../services/skincraft_embed'; -import {toSkinCraftItem} from '../../services/skincraft_inventory_targets'; +import {getActiveInventoryAssetProperties, toSkinCraftItem} from '../../services/skincraft_inventory_targets'; import type {SkinCraftItem} from '../../services/skincraft_viewer_protocol'; import {gWebGpuAvailability} from '../../services/webgpu_availability'; import type {WebGpuAvailability} from '../../services/webgpu_availability'; @@ -286,7 +286,7 @@ export class SelectedItemInfo extends FloatElement { } private get skinCraftItem(): SkinCraftItem | undefined { - return toSkinCraftItem(this.asset); + return toSkinCraftItem(this.asset, getActiveInventoryAssetProperties(g_ActiveInventory, this.asset?.assetid)); } private get canViewInSkinCraft(): boolean { diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 46c9c466..747dcc16 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -61,6 +61,28 @@ export function toSkinCraftItem( }; } +/** Property lookup for a single active-inventory asset, covering appwide child inventories. */ +export function getActiveInventoryAssetProperties( + activeInventory: CInventory | CAppwideInventory | undefined, + assetId: string | undefined +): rgAssetProperty[] | undefined { + if (!activeInventory || !assetId) return; + + const inventories = isCAppwideInventory(activeInventory) + ? [ + activeInventory.m_rgChildInventories[ContextId.PRIMARY], + activeInventory.m_rgChildInventories[ContextId.PROTECTED], + activeInventory, + ] + : [activeInventory]; + + for (const inventory of inventories) { + const properties = inventory?.m_rgAssetProperties?.[assetId]; + if (properties?.length) return properties; + } + return undefined; +} + export function getLoadedInventoryTargets( activeInventory: CInventory | CAppwideInventory, getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo From 587fd981fca326dc8569c5282d37009215d2746d Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:46:21 -0400 Subject: [PATCH 16/17] fix(inventory): make the SkinCraft load timeout terminal --- src/lib/services/skincraft_embed.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 1fbc22e7..47873f4f 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -245,9 +245,10 @@ class SkinCraftEmbedService { this.loadPhase = 'error'; this.showLoadingCover = false; this.frameHasContent = false; - // Drop the queued load so a late `ready` can't start it under the error UI — recovery - // goes through the Retry button. + // Drop the queued load and forget the in-flight id so neither a late `ready` nor a + // late `loaded` can act under the error UI — recovery goes through the Retry button. this.pendingInspect = undefined; + this.latestLoadId = undefined; this.modal?.setError('The 3D viewer took too long to load.'); }, LOAD_TIMEOUT_MS); } From 0afeb89281cf161b18ebd299d8dd1a7388db7282 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Tue, 28 Jul 2026 18:53:42 -0400 Subject: [PATCH 17/17] fix(inventory): correlate terminal SkinCraft events by load id --- src/lib/services/skincraft_embed.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 47873f4f..e8e2b31b 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -202,17 +202,25 @@ class SkinCraftEmbedService { } }; + // Id-less events stay accepted here because the embed's boot-time progress predates our first + // `load` command and so carries no id. private acceptsLoadEvent(id?: string): boolean { return this.active && (!id || id === this.latestLoadId); } + // Terminal events echo our load id, so they must correlate — a stale or timed-out load can't + // dismiss newer UI. + private isLatestLoad(id?: string): boolean { + return this.active && id !== undefined && id === this.latestLoadId; + } + private acceptsTerminalEvent(id?: string): boolean { - return (this.loadPhase === 'loading' || this.loadPhase === 'error') && this.acceptsLoadEvent(id); + return (this.loadPhase === 'loading' || this.loadPhase === 'error') && this.isLatestLoad(id); } /** Unlike `loaded`, an error can also invalidate a model that already loaded (e.g. a lost GPU device). */ private acceptsErrorEvent(id?: string): boolean { - return this.loadPhase !== 'idle' && this.acceptsLoadEvent(id); + return this.loadPhase !== 'idle' && this.isLatestLoad(id); } private handleVisibilityChange = (): void => {