Spaces:
Running
Running
Skill icons: show the spinner while painting, not a stale reused-id icon
Browse filesForged skill ids live in a reused 9000+ band, so getSkillIcon(id) could return a
PREVIOUS skill's art for a brand-new pending one — the loading spinner never
showed; the old icon did. Two layers of fix:
- paintSkillIcon / skill detail / forge card now check iconPending FIRST and show
the spinner (clearing any background image), only fetching the stored icon once
it's no longer pending.
- forgeSkillForHero deletes any existing icon for the (reused) id up front, so the
store starts clean — new delSkillIcon() in personaStore.
Also: Replay tour button on the hero-selection screen (only when no hero is
picked yet) → tut.reset() restarts the guided tour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaStore.js +1 -0
- web/skillForge.js +2 -1
- web/skillForgePanel.js +2 -3
- web/tiny.js +17 -6
web/personaStore.js
CHANGED
|
@@ -176,6 +176,7 @@ export const getPortrait = (id) => _get(PSTORE, id)
|
|
| 176 |
// Skill media is keyed by skillId; chat audio by message id.
|
| 177 |
export const putSkillIcon = (skillId, blob) => _put(ICONS, skillId, blob)
|
| 178 |
export const getSkillIcon = (skillId) => _get(ICONS, skillId)
|
|
|
|
| 179 |
export const putSkillArt = (skillId, blob) => _put(ART, skillId, blob)
|
| 180 |
export const getSkillArt = (skillId) => _get(ART, skillId)
|
| 181 |
export const putChatAudio = (msgId, blob) => _put(CHATAUDIO, msgId, blob)
|
|
|
|
| 176 |
// Skill media is keyed by skillId; chat audio by message id.
|
| 177 |
export const putSkillIcon = (skillId, blob) => _put(ICONS, skillId, blob)
|
| 178 |
export const getSkillIcon = (skillId) => _get(ICONS, skillId)
|
| 179 |
+
export const delSkillIcon = (skillId) => _del(ICONS, skillId)
|
| 180 |
export const putSkillArt = (skillId, blob) => _put(ART, skillId, blob)
|
| 181 |
export const getSkillArt = (skillId) => _get(ART, skillId)
|
| 182 |
export const putChatAudio = (msgId, blob) => _put(CHATAUDIO, msgId, blob)
|
web/skillForge.js
CHANGED
|
@@ -7,7 +7,7 @@ import { streamCoding, currentCodingModel } from '/web/codingModel.js'
|
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
-
import { getPersona, patchPersona, putSkillIcon, getPortrait } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
| 12 |
|
| 13 |
export const FORGE_SYSTEM = [
|
|
@@ -103,6 +103,7 @@ export async function forgeSkillForHero(personaId, request, { onStatus, onToken,
|
|
| 103 |
if (!v.ok) return { ok: false, error: v.errors.join('; ') }
|
| 104 |
|
| 105 |
const skill = { ...v.skill, iconPending: true } // image paints in the background
|
|
|
|
| 106 |
const fresh = getPersona(p.id) || p
|
| 107 |
patchPersona(p.id, {
|
| 108 |
customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
|
|
|
|
| 7 |
import { stripThinkFinal } from '/web/personaPrompts.js'
|
| 8 |
import { validateSkill, SAFE_OPS, SAFE_CONDITIONS, ELEMENTS, SHAPES } from '/web/skillSchema.js'
|
| 9 |
import { generatePortrait } from '/web/imagen.js'
|
| 10 |
+
import { getPersona, patchPersona, putSkillIcon, delSkillIcon, getPortrait } from '/web/personaStore.js'
|
| 11 |
import { appendEvent } from '/web/progression.js'
|
| 12 |
|
| 13 |
export const FORGE_SYSTEM = [
|
|
|
|
| 103 |
if (!v.ok) return { ok: false, error: v.errors.join('; ') }
|
| 104 |
|
| 105 |
const skill = { ...v.skill, iconPending: true } // image paints in the background
|
| 106 |
+
try { await delSkillIcon(skill.id) } catch { /* ignore */ } // forged ids are reused — drop any old art so the spinner shows, not a stale icon
|
| 107 |
const fresh = getPersona(p.id) || p
|
| 108 |
patchPersona(p.id, {
|
| 109 |
customSkills: { ...(fresh.customSkills || {}), [skill.id]: skill },
|
web/skillForgePanel.js
CHANGED
|
@@ -69,9 +69,8 @@ export function mountSkillForgePanel(host) {
|
|
| 69 |
card.replaceChildren()
|
| 70 |
const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
|
| 71 |
const icon = el('div', { style: 'position:relative;width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
|
| 72 |
-
|
| 73 |
-
if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})`
|
| 74 |
-
else if (skill.iconPending) icon.append(el('div', { class: 'ta-skill-spin', title: 'painting its scene…' })) // loading indicator (CSS injected by tiny.js)
|
| 75 |
const meta = el('div', {}, [
|
| 76 |
el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
|
| 77 |
el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
|
|
|
|
| 69 |
card.replaceChildren()
|
| 70 |
const head = el('div', { style: 'display:flex;gap:12px;align-items:center' })
|
| 71 |
const icon = el('div', { style: 'position:relative;width:64px;height:64px;border-radius:10px;border:1px solid #2a3340;background:#0d1015;flex:none;background-size:cover;background-position:center' })
|
| 72 |
+
if (skill.iconPending) icon.append(el('div', { class: 'ta-skill-spin', title: 'painting its scene…' })) // still painting → spinner, never a stale (reused-id) icon
|
| 73 |
+
else { const b = await getSkillIcon(skill.id); if (b) icon.style.backgroundImage = `url(${URL.createObjectURL(b)})` }
|
|
|
|
| 74 |
const meta = el('div', {}, [
|
| 75 |
el('div', { style: 'font:700 16px var(--tac-font,system-ui);color:#e8e8e8' }, skill.name),
|
| 76 |
el('div', { style: 'font-size:11px;color:#9aa4b2;text-transform:uppercase;letter-spacing:.06em;margin:2px 0' }, `${skill.category.replace('_', ' ')} · ${effectSummary(skill)}`),
|
web/tiny.js
CHANGED
|
@@ -98,10 +98,16 @@ function whenEl(id, cb) {
|
|
| 98 |
// the background (iconPending). Shared by the bar slots + skill cards.
|
| 99 |
;(function injectSkillSpin() { try { const s = document.createElement('style'); s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'; document.head.appendChild(s) } catch { /* ignore */ } })()
|
| 100 |
function paintSkillIcon(box, sk) {
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
| 106 |
|
| 107 |
// Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
|
|
@@ -353,7 +359,11 @@ function buildHeroPicker(host, personas, chars, onPick, onCreate) {
|
|
| 353 |
create.append(plus, clbl)
|
| 354 |
create.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onCreate && onCreate() })
|
| 355 |
row.appendChild(create)
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
return bar
|
| 358 |
}
|
| 359 |
// "Create a Hero" modal over the map: hosts the shared creator (heroCreator.js — stream details,
|
|
@@ -528,7 +538,8 @@ whenEl('battle-stage', async (el) => {
|
|
| 528 |
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
|
| 529 |
back.append(sh); el.append(back); skillSheet = back
|
| 530 |
// The skill's single image (the character performing it) — generated in the background by the forge.
|
| 531 |
-
|
|
|
|
| 532 |
}
|
| 533 |
// Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
|
| 534 |
let chatSheet = null
|
|
|
|
| 98 |
// the background (iconPending). Shared by the bar slots + skill cards.
|
| 99 |
;(function injectSkillSpin() { try { const s = document.createElement('style'); s.textContent = '@keyframes ta-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}.ta-skill-spin{position:absolute;top:50%;left:50%;width:18px;height:18px;border:2px solid #2a3340;border-top-color:#c9a227;border-radius:50%;animation:ta-spin .8s linear infinite}'; document.head.appendChild(s) } catch { /* ignore */ } })()
|
| 100 |
function paintSkillIcon(box, sk) {
|
| 101 |
+
// While the scene is still painting, ALWAYS show the spinner — never a leftover icon. Forged skill
|
| 102 |
+
// ids are reused (9000+ band), so getSkillIcon(id) can return a previous skill's art for a brand-new
|
| 103 |
+
// pending one; checking iconPending first keeps that stale image from flashing in.
|
| 104 |
+
if (sk.iconPending) {
|
| 105 |
+
box.style.backgroundImage = ''
|
| 106 |
+
box.style.position = box.style.position || 'relative'
|
| 107 |
+
const sp = document.createElement('div'); sp.className = 'ta-skill-spin'; box.appendChild(sp)
|
| 108 |
+
return
|
| 109 |
+
}
|
| 110 |
+
getSkillIcon(sk.id).then((b) => { if (b) box.style.backgroundImage = `url(${URL.createObjectURL(b)})` })
|
| 111 |
}
|
| 112 |
|
| 113 |
// Gradio wraps each gr.HTML block in a `<div class="prose gradio-style …">` whose versioned rules
|
|
|
|
| 359 |
create.append(plus, clbl)
|
| 360 |
create.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); onCreate && onCreate() })
|
| 361 |
row.appendChild(create)
|
| 362 |
+
// Replay the guided tour — only here, on the hero-selection screen (no hero picked yet).
|
| 363 |
+
const replay = document.createElement('button'); replay.type = 'button'; replay.textContent = '↻ Replay tour'
|
| 364 |
+
replay.style.cssText = 'pointer-events:auto;background:none;border:none;color:#8a93a0;font:600 11px var(--tac-font,system-ui);letter-spacing:.04em;cursor:pointer;padding:4px 8px;text-shadow:0 1px 3px #000'
|
| 365 |
+
replay.addEventListener('pointerdown', (ev) => { ev.preventDefault(); ev.stopPropagation(); try { tut.reset() } catch { /* ignore */ } })
|
| 366 |
+
bar.append(title, row, replay); host.appendChild(bar)
|
| 367 |
return bar
|
| 368 |
}
|
| 369 |
// "Create a Hero" modal over the map: hosts the shared creator (heroCreator.js — stream details,
|
|
|
|
| 538 |
back.addEventListener('pointerdown', (e) => { if (e.target === back) closeDetail() })
|
| 539 |
back.append(sh); el.append(back); skillSheet = back
|
| 540 |
// The skill's single image (the character performing it) — generated in the background by the forge.
|
| 541 |
+
// While pending, keep the "painting…" label and don't pull a stale (reused-id) icon from the store.
|
| 542 |
+
if (!sk.iconPending) getSkillIcon(sk.id).then((b) => { if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})` } else art.textContent = 'no image' })
|
| 543 |
}
|
| 544 |
// Chat with the current hero in a bottom-sheet (woid-context chat + per-message voice).
|
| 545 |
let chatSheet = null
|