polats Claude Opus 4.8 (1M context) commited on
Commit
e46cfbf
·
1 Parent(s): 7232e98

Skill icons: show the spinner while painting, not a stale reused-id icon

Browse files

Forged 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 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
- const b = await getSkillIcon(skill.id)
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
- getSkillIcon(sk.id).then((b) => {
102
- if (b) { box.style.backgroundImage = `url(${URL.createObjectURL(b)})` }
103
- else if (sk.iconPending) { box.style.position = box.style.position || 'relative'; const sp = document.createElement('div'); sp.className = 'ta-skill-spin'; box.appendChild(sp) }
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
- bar.append(title, row); host.appendChild(bar)
 
 
 
 
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
- getSkillIcon(sk.id).then((b) => { if (b) { art.textContent = ''; art.style.backgroundImage = `url(${URL.createObjectURL(b)})` } else if (!sk.iconPending) art.textContent = 'no image' })
 
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