Spaces:
Running
Portraits step 3: persona panel Portrait section + per-hero caching
Browse files- New "Portrait" section in the hero panel: an editable appearance prompt (auto-
built from name/class/about) and a 🎨 paint button that generates a portrait via
the active image provider (local Z-Image or cloud FLUX), shown beneath it.
- Reuses the voice spinner/badge UX (now generalized to .persona-ico): the button
badges when there's no portrait or the appearance changed, spins while painting.
- Per-hero PNG cached in IndexedDB (new 'portraits' store, DB v2) and the appearance
prompt persisted, so portraits survive a refresh and live in the barracks.
Verified locally: section renders with auto-appearance; 🎨 paints a 1024² portrait
(~15 s on the 3090) that renders + persists across reload; badge clears when current.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- web/personaPanel.js +63 -5
- web/personaStore.js +23 -11
- web/shell/persona.css +17 -5
|
@@ -11,7 +11,8 @@ import {
|
|
| 11 |
createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
|
| 12 |
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
|
| 13 |
} from '/web/tts.js'
|
| 14 |
-
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio } from '/web/personaStore.js'
|
|
|
|
| 15 |
|
| 16 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 17 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
@@ -155,6 +156,12 @@ export function mountPersonaPanel(host) {
|
|
| 155 |
// needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
|
| 156 |
// the quote/voice was edited since the last one was made.
|
| 157 |
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 159 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 160 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
@@ -176,6 +183,7 @@ export function mountPersonaPanel(host) {
|
|
| 176 |
secHead('About'), aboutEl,
|
| 177 |
secHead('Quote', playBtn), quoteEl,
|
| 178 |
secHead('Voice design'), voiceEl, voicePickRow,
|
|
|
|
| 179 |
])
|
| 180 |
const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
|
| 181 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
|
@@ -183,6 +191,9 @@ export function mountPersonaPanel(host) {
|
|
| 183 |
let lastPersona = null // the persona currently shown
|
| 184 |
let savedId = null // its roster id (set the moment it's shown — always saved)
|
| 185 |
let hasVoice = false // a cached voice file exists for this persona
|
|
|
|
|
|
|
|
|
|
| 186 |
let working = false
|
| 187 |
let busy = false
|
| 188 |
let playing = false // audio is currently sounding (▶ becomes ⏹)
|
|
@@ -286,6 +297,49 @@ export function mountPersonaPanel(host) {
|
|
| 286 |
editable(quoteEl, 'quote', { single: true })
|
| 287 |
editable(voiceEl, 'voice')
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
async function showPersona(p, opts = {}) {
|
| 290 |
stopVoice() // picking another hero cuts the current voice
|
| 291 |
lastPersona = { ...p }
|
|
@@ -294,8 +348,12 @@ export function mountPersonaPanel(host) {
|
|
| 294 |
aboutEl.textContent = p.about || ''
|
| 295 |
quoteEl.textContent = p.quote || ''
|
| 296 |
voiceEl.textContent = p.voice || ''
|
|
|
|
| 297 |
hasVoice = savedId ? !!(await getAudio(savedId)) : false
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
| 299 |
}
|
| 300 |
|
| 301 |
// ▶ The one voice button: if the cached voice is current, just replay it. If the voice
|
|
@@ -409,9 +467,9 @@ export function mountPersonaPanel(host) {
|
|
| 409 |
busy = true; btn.disabled = true; refreshVisibility()
|
| 410 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 411 |
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 412 |
-
quoteEl.textContent = ''; voiceEl.textContent = ''
|
| 413 |
-
lastPersona = null; savedId = null; hasVoice = false
|
| 414 |
-
stopVoice(); updateVoiceUI()
|
| 415 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 416 |
let acc = ''
|
| 417 |
try {
|
|
|
|
| 11 |
createVoiceWav, cloneVoiceWav, playWav, synthVoiceWav, speakVoiceLive, stopVoiceLive,
|
| 12 |
activeEngineIsDesign, activeEngineIsNative, activeVoices, activeDefaultVoice, onTtsEngineChange,
|
| 13 |
} from '/web/tts.js'
|
| 14 |
+
import { listPersonas, savePersona, removePersona, onRosterChange, putAudio, getAudio, putPortrait, getPortrait } from '/web/personaStore.js'
|
| 15 |
+
import { generatePortrait, imageBackendLabel } from '/web/imagen.js'
|
| 16 |
|
| 17 |
const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
|
| 18 |
const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
|
|
|
|
| 156 |
// needed and replays it otherwise. A pulsing badge shows when there's no voice yet, or
|
| 157 |
// the quote/voice was edited since the last one was made.
|
| 158 |
const playBtn = el('button', { class: 'persona-ico persona-play', type: 'button', title: 'Play voice' }, '▶')
|
| 159 |
+
// Portrait: an editable appearance prompt + a 🎨 button that paints it (Z-Image/FLUX),
|
| 160 |
+
// cached per hero. Badge pulses when there's no portrait yet or the appearance changed.
|
| 161 |
+
const appearanceEl = el('div', { class: 'persona-appearance persona-edit', 'data-ph': 'How they look…' })
|
| 162 |
+
const portraitBtn = el('button', { class: 'persona-ico persona-portrait-btn', type: 'button', title: 'Paint portrait' }, '🎨')
|
| 163 |
+
const portraitImg = el('img', { class: 'persona-portrait-img', alt: '' })
|
| 164 |
+
const portraitWrap = el('div', { class: 'persona-portrait-wrap' }, [portraitImg])
|
| 165 |
const thinkEl = el('pre', { class: 'persona-think' })
|
| 166 |
const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
|
| 167 |
const thinkWrap = el('details', { class: 'persona-think-wrap' },
|
|
|
|
| 183 |
secHead('About'), aboutEl,
|
| 184 |
secHead('Quote', playBtn), quoteEl,
|
| 185 |
secHead('Voice design'), voiceEl, voicePickRow,
|
| 186 |
+
secHead('Portrait', portraitBtn), appearanceEl, portraitWrap,
|
| 187 |
])
|
| 188 |
const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
|
| 189 |
host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
|
|
|
|
| 191 |
let lastPersona = null // the persona currently shown
|
| 192 |
let savedId = null // its roster id (set the moment it's shown — always saved)
|
| 193 |
let hasVoice = false // a cached voice file exists for this persona
|
| 194 |
+
let hasPortrait = false // a cached portrait exists for this persona
|
| 195 |
+
let portraitBusy = false
|
| 196 |
+
let portraitUrl = null // object URL for the shown image (revoked on replace)
|
| 197 |
let working = false
|
| 198 |
let busy = false
|
| 199 |
let playing = false // audio is currently sounding (▶ becomes ⏹)
|
|
|
|
| 297 |
editable(quoteEl, 'quote', { single: true })
|
| 298 |
editable(voiceEl, 'voice')
|
| 299 |
|
| 300 |
+
// ── Portrait ──────────────────────────────────────────────────────────────
|
| 301 |
+
const PORTRAIT_STYLE = 'Fantasy hero character portrait, painterly digital art, dramatic lighting, head and shoulders, detailed face, plain background.'
|
| 302 |
+
const buildAppearance = (p) => [
|
| 303 |
+
[p.name, p.unitClass && `a ${p.unitClass}`].filter(Boolean).join(', '),
|
| 304 |
+
(p.about || '').trim(),
|
| 305 |
+
].filter(Boolean).join('. ')
|
| 306 |
+
const appearanceFor = (p) => (p.appearance || '').trim() || buildAppearance(p)
|
| 307 |
+
const portraitDirty = () => hasPortrait && lastPersona && appearanceFor(lastPersona) !== (lastPersona.portraitUsed || '')
|
| 308 |
+
|
| 309 |
+
function setPortrait(blob) {
|
| 310 |
+
if (portraitUrl) { try { URL.revokeObjectURL(portraitUrl) } catch { /* ignore */ } }
|
| 311 |
+
portraitUrl = blob ? URL.createObjectURL(blob) : null
|
| 312 |
+
portraitImg.src = portraitUrl || ''
|
| 313 |
+
portraitWrap.classList.toggle('has-img', !!blob)
|
| 314 |
+
}
|
| 315 |
+
function updatePortraitUI() {
|
| 316 |
+
portraitBtn.classList.toggle('badged', !!lastPersona && (!hasPortrait || portraitDirty()))
|
| 317 |
+
portraitBtn.title = !hasPortrait ? 'Paint portrait' : 'Repaint portrait'
|
| 318 |
+
}
|
| 319 |
+
// Make the appearance field click-to-edit (drives the prompt + the badge).
|
| 320 |
+
appearanceEl.contentEditable = 'true'; appearanceEl.spellcheck = false
|
| 321 |
+
appearanceEl.addEventListener('input', () => { if (lastPersona) { lastPersona.appearance = appearanceEl.textContent.trim(); updatePortraitUI() } })
|
| 322 |
+
appearanceEl.addEventListener('blur', () => { if (!lastPersona) return; lastPersona.appearance = appearanceEl.textContent.trim(); autosave(); updatePortraitUI() })
|
| 323 |
+
|
| 324 |
+
// 🎨 Generate (or regenerate) the portrait from the appearance prompt; cache + persist.
|
| 325 |
+
async function makePortrait() {
|
| 326 |
+
if (portraitBusy || !lastPersona) return
|
| 327 |
+
autosave() // ensure an id to key the image
|
| 328 |
+
const appearance = appearanceFor(lastPersona)
|
| 329 |
+
portraitBusy = true; portraitBtn.classList.add('busy'); portraitBtn.disabled = true
|
| 330 |
+
const prev = status.textContent
|
| 331 |
+
status.textContent = `painting with ${imageBackendLabel()}…`
|
| 332 |
+
try {
|
| 333 |
+
const blob = await generatePortrait(`${appearance}. ${PORTRAIT_STYLE}`, { seed: 42 })
|
| 334 |
+
await putPortrait(savedId, blob)
|
| 335 |
+
lastPersona.appearance = appearance; lastPersona.portraitUsed = appearance
|
| 336 |
+
hasPortrait = true; setPortrait(blob); autosave()
|
| 337 |
+
status.textContent = prev
|
| 338 |
+
} catch (e) { status.textContent = `portrait failed: ${e.message || e}` }
|
| 339 |
+
finally { portraitBusy = false; portraitBtn.classList.remove('busy'); portraitBtn.disabled = false; updatePortraitUI() }
|
| 340 |
+
}
|
| 341 |
+
portraitBtn.addEventListener('click', makePortrait)
|
| 342 |
+
|
| 343 |
async function showPersona(p, opts = {}) {
|
| 344 |
stopVoice() // picking another hero cuts the current voice
|
| 345 |
lastPersona = { ...p }
|
|
|
|
| 348 |
aboutEl.textContent = p.about || ''
|
| 349 |
quoteEl.textContent = p.quote || ''
|
| 350 |
voiceEl.textContent = p.voice || ''
|
| 351 |
+
appearanceEl.textContent = p.appearance || buildAppearance(p)
|
| 352 |
hasVoice = savedId ? !!(await getAudio(savedId)) : false
|
| 353 |
+
let pblob = null
|
| 354 |
+
hasPortrait = savedId ? !!(pblob = await getPortrait(savedId)) : false
|
| 355 |
+
setPortrait(pblob)
|
| 356 |
+
refreshVoiceMode(); updateVoiceUI(); updatePortraitUI(); refreshVisibility()
|
| 357 |
}
|
| 358 |
|
| 359 |
// ▶ The one voice button: if the cached voice is current, just replay it. If the voice
|
|
|
|
| 467 |
busy = true; btn.disabled = true; refreshVisibility()
|
| 468 |
if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
| 469 |
nameEl.textContent = '…'; aboutEl.textContent = ''
|
| 470 |
+
quoteEl.textContent = ''; voiceEl.textContent = ''; appearanceEl.textContent = ''
|
| 471 |
+
lastPersona = null; savedId = null; hasVoice = false; hasPortrait = false
|
| 472 |
+
stopVoice(); setPortrait(null); updateVoiceUI(); updatePortraitUI()
|
| 473 |
thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
|
| 474 |
let acc = ''
|
| 475 |
try {
|
|
@@ -50,6 +50,10 @@ export function savePersona(p) {
|
|
| 50 |
voiceQuote: p.voiceQuote || '',
|
| 51 |
voiceDesignUsed: p.voiceDesignUsed || '',
|
| 52 |
voiceIdUsed: p.voiceIdUsed || '',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
createdAt: now,
|
| 54 |
updatedAt: now,
|
| 55 |
}
|
|
@@ -64,35 +68,43 @@ export function removePersona(id) {
|
|
| 64 |
const d = read()
|
| 65 |
const next = d.personas.filter((x) => x.id !== id)
|
| 66 |
if (next.length !== d.personas.length) write({ personas: next })
|
| 67 |
-
|
| 68 |
}
|
| 69 |
|
| 70 |
-
// ──
|
| 71 |
-
const DB = 'tinyarmy', STORE = 'voices'
|
| 72 |
let _dbp = null
|
| 73 |
function db() {
|
| 74 |
if (!_dbp) {
|
| 75 |
_dbp = new Promise((res, rej) => {
|
| 76 |
-
const r = indexedDB.open(DB,
|
| 77 |
-
r.onupgradeneeded = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
r.onsuccess = () => res(r.result)
|
| 79 |
r.onerror = () => rej(r.error)
|
| 80 |
})
|
| 81 |
}
|
| 82 |
return _dbp
|
| 83 |
}
|
| 84 |
-
|
| 85 |
try {
|
| 86 |
const d = await db()
|
| 87 |
-
await new Promise((res, rej) => { const t = d.transaction(
|
| 88 |
} catch { /* best-effort */ }
|
| 89 |
}
|
| 90 |
-
|
| 91 |
try {
|
| 92 |
const d = await db()
|
| 93 |
-
return await new Promise((res) => { const t = d.transaction(
|
| 94 |
} catch { return null }
|
| 95 |
}
|
| 96 |
-
async function
|
| 97 |
-
try { const d = await db(); d.transaction(
|
| 98 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
voiceQuote: p.voiceQuote || '',
|
| 51 |
voiceDesignUsed: p.voiceDesignUsed || '',
|
| 52 |
voiceIdUsed: p.voiceIdUsed || '',
|
| 53 |
+
// Portrait: the editable appearance prompt + what the cached image (IndexedDB) was
|
| 54 |
+
// made from, so a reload knows the image is current vs stale (re-generate it).
|
| 55 |
+
appearance: p.appearance || '',
|
| 56 |
+
portraitUsed: p.portraitUsed || '',
|
| 57 |
createdAt: now,
|
| 58 |
updatedAt: now,
|
| 59 |
}
|
|
|
|
| 68 |
const d = read()
|
| 69 |
const next = d.personas.filter((x) => x.id !== id)
|
| 70 |
if (next.length !== d.personas.length) write({ personas: next })
|
| 71 |
+
_del(STORE, id); _del(PSTORE, id)
|
| 72 |
}
|
| 73 |
|
| 74 |
+
// ── Media store (IndexedDB — WAV + PNG blobs are too big for localStorage) ──────
|
| 75 |
+
const DB = 'tinyarmy', STORE = 'voices', PSTORE = 'portraits'
|
| 76 |
let _dbp = null
|
| 77 |
function db() {
|
| 78 |
if (!_dbp) {
|
| 79 |
_dbp = new Promise((res, rej) => {
|
| 80 |
+
const r = indexedDB.open(DB, 2) // v2 adds the 'portraits' store alongside 'voices'
|
| 81 |
+
r.onupgradeneeded = () => {
|
| 82 |
+
const d = r.result
|
| 83 |
+
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE)
|
| 84 |
+
if (!d.objectStoreNames.contains(PSTORE)) d.createObjectStore(PSTORE)
|
| 85 |
+
}
|
| 86 |
r.onsuccess = () => res(r.result)
|
| 87 |
r.onerror = () => rej(r.error)
|
| 88 |
})
|
| 89 |
}
|
| 90 |
return _dbp
|
| 91 |
}
|
| 92 |
+
async function _put(store, id, blob) {
|
| 93 |
try {
|
| 94 |
const d = await db()
|
| 95 |
+
await new Promise((res, rej) => { const t = d.transaction(store, 'readwrite'); t.objectStore(store).put(blob, id); t.oncomplete = res; t.onerror = () => rej(t.error) })
|
| 96 |
} catch { /* best-effort */ }
|
| 97 |
}
|
| 98 |
+
async function _get(store, id) {
|
| 99 |
try {
|
| 100 |
const d = await db()
|
| 101 |
+
return await new Promise((res) => { const t = d.transaction(store, 'readonly'); const q = t.objectStore(store).get(id); q.onsuccess = () => res(q.result || null); q.onerror = () => res(null) })
|
| 102 |
} catch { return null }
|
| 103 |
}
|
| 104 |
+
async function _del(store, id) {
|
| 105 |
+
try { const d = await db(); d.transaction(store, 'readwrite').objectStore(store).delete(id) } catch { /* ignore */ }
|
| 106 |
}
|
| 107 |
+
export const putAudio = (id, blob) => _put(STORE, id, blob)
|
| 108 |
+
export const getAudio = (id) => _get(STORE, id)
|
| 109 |
+
export const putPortrait = (id, blob) => _put(PSTORE, id, blob)
|
| 110 |
+
export const getPortrait = (id) => _get(PSTORE, id)
|
|
@@ -111,6 +111,18 @@
|
|
| 111 |
.persona-voice-pick-row { margin-top: 12px; max-width: 320px; }
|
| 112 |
.persona-voice-pick-row .persona-label { margin-top: 0; }
|
| 113 |
.persona-voice-pick { margin-top: 4px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
.persona-quote {
|
| 115 |
margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
|
| 116 |
font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
|
|
@@ -127,16 +139,16 @@
|
|
| 127 |
}
|
| 128 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
| 129 |
.persona-ico.busy { cursor: default; }
|
| 130 |
-
/*
|
| 131 |
-
.persona-
|
| 132 |
-
.persona-
|
| 133 |
content: ''; position: absolute; inset: 0; margin: auto; width: 11px; height: 11px;
|
| 134 |
border: 2px solid var(--p-paper-2); border-top-color: var(--p-transmit); border-radius: 50%;
|
| 135 |
animation: tac-spin .7s linear infinite;
|
| 136 |
}
|
| 137 |
@keyframes tac-spin { to { transform: rotate(360deg); } }
|
| 138 |
-
/* Badge = "
|
| 139 |
-
.persona-
|
| 140 |
content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
|
| 141 |
background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
|
| 142 |
animation: tac-badge-pulse 1.3s ease-out infinite;
|
|
|
|
| 111 |
.persona-voice-pick-row { margin-top: 12px; max-width: 320px; }
|
| 112 |
.persona-voice-pick-row .persona-label { margin-top: 0; }
|
| 113 |
.persona-voice-pick { margin-top: 4px; }
|
| 114 |
+
|
| 115 |
+
/* Portrait — an editable appearance prompt + the painted image. */
|
| 116 |
+
.persona-appearance {
|
| 117 |
+
font-family: var(--p-mono); font-size: 12px; line-height: 1.5; color: var(--p-muted);
|
| 118 |
+
max-width: 60ch; margin-top: 8px; font-style: italic;
|
| 119 |
+
}
|
| 120 |
+
.persona-portrait-wrap { margin-top: 12px; }
|
| 121 |
+
.persona-portrait-wrap:not(.has-img) { display: none; }
|
| 122 |
+
.persona-portrait-img {
|
| 123 |
+
width: 320px; max-width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block;
|
| 124 |
+
border: 1.5px solid var(--p-ink); box-shadow: 4px 4px 0 var(--p-transmit); background: var(--p-card);
|
| 125 |
+
}
|
| 126 |
.persona-quote {
|
| 127 |
margin: 8px 0 0; padding: 4px 0 4px 16px; border-left: 3px solid var(--p-transmit);
|
| 128 |
font-family: 'Fraunces', Georgia, serif; font-size: 21px; font-style: italic;
|
|
|
|
| 139 |
}
|
| 140 |
.persona-ico:hover { background: var(--p-paper-2) !important; }
|
| 141 |
.persona-ico.busy { cursor: default; }
|
| 142 |
+
/* Working (voice/portrait) → hide the glyph and spin a small ring in its place. */
|
| 143 |
+
.persona-ico.busy { color: transparent !important; }
|
| 144 |
+
.persona-ico.busy::before {
|
| 145 |
content: ''; position: absolute; inset: 0; margin: auto; width: 11px; height: 11px;
|
| 146 |
border: 2px solid var(--p-paper-2); border-top-color: var(--p-transmit); border-radius: 50%;
|
| 147 |
animation: tac-spin .7s linear infinite;
|
| 148 |
}
|
| 149 |
@keyframes tac-spin { to { transform: rotate(360deg); } }
|
| 150 |
+
/* Badge = "nothing made yet, or the inputs changed — tap to (re)make it." Pulses. */
|
| 151 |
+
.persona-ico.badged::after {
|
| 152 |
content: ''; position: absolute; top: -4px; right: -4px; width: 9px; height: 9px;
|
| 153 |
background: var(--p-transmit); border: 1.5px solid var(--p-card); border-radius: 50%;
|
| 154 |
animation: tac-badge-pulse 1.3s ease-out infinite;
|