polats Claude Opus 4.8 (1M context) commited on
Commit
0ecdf98
·
1 Parent(s): fc20b4d

Personas-as-agents: spoken quote + save to a local-first barracks roster

Browse files

Each persona now also gets a `quote` (a short spoken line). "Hear voice" designs the
voice and speaks the QUOTE (not the bio). New personaStore.js — a local-first roster
(localStorage JSON blob + CRUD + change listeners + a pluggable sync hook), modeled on
woid's Shelter store — so saved soldiers persist for returning visitors. Persona panel
gains "💾 Save to barracks" + a roster list (view / remove); MAX_TOKENS 160→200 for the
extra fields. Verified: save → reload → persists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

web/personaPanel.js CHANGED
@@ -1,15 +1,16 @@
1
- // Tiny Army persona panel — vanilla DOM, mounted by tiny.js into #persona-stage.
2
- // Generation runs ON THE USER'S DEVICE via the chosen engine (Settings). Streams into a
3
- // live "thinking" view + parsed result, shows tok/s, and exposes a one-tap "Copy debug"
4
- // report (engine/model/raw output/error) so failures can be pasted back for triage.
5
  import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
6
  import { extractLivePersona } from '/web/personaStream.js'
7
  import { parsePersonaJson } from '/web/personaParse.js'
8
  import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
9
  import { previewVoice, stopPreview } from '/web/tts.js'
 
10
 
11
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
12
- const MAX_TOKENS = 160 // short personas + a one-line voice description for Qwen3-TTS
13
 
14
  function el(tag, props = {}, kids = []) {
15
  const n = document.createElement(tag)
@@ -28,12 +29,15 @@ export function mountPersonaPanel(host) {
28
  const stats = el('div', { class: 'persona-stats' })
29
  const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
30
  const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
 
31
 
32
  const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
33
  const tagsEl = el('div', { class: 'persona-tags' })
34
- const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small model in your browser writes their legend.')
 
35
  const voiceEl = el('div', { class: 'persona-voice-desc' })
36
  const hearBtn = el('button', { class: 'persona-go persona-go-alt persona-hear', type: 'button', style: 'display:none' }, '🔊 Hear voice')
 
37
  const thinkEl = el('pre', { class: 'persona-think' })
38
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
39
  const thinkWrap = el('details', { class: 'persona-think-wrap' },
@@ -43,37 +47,69 @@ export function mountPersonaPanel(host) {
43
  el('label', { class: 'persona-label' }, 'Class'), sel,
44
  el('label', { class: 'persona-label' }, 'Seed'), seed,
45
  btn, stats, status,
 
46
  ])
47
- const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, aboutEl, voiceEl, hearBtn, thinkWrap])
48
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
49
 
50
- // 🔊 Hear voice design this soldier's voice from their `voice` line (Qwen3-TTS via
51
- // DashScope) and read their `about` aloud. Networked (not local-first).
52
- let lastPersona = null
53
  let hearing = false
54
- hearBtn.addEventListener('click', async () => {
55
- if (hearing) { stopPreview(); return } // onfinish below resets the label
56
- if (!lastPersona || !lastPersona.voice) return
57
- hearing = true; hearBtn.textContent = '⏹ Stop'
58
- const prev = status.textContent
59
- status.textContent = '☁ designing the voice on DashScope…'
60
- try {
61
- await previewVoice(lastPersona.voice, lastPersona.about || lastPersona.name || 'Hello.')
62
- status.textContent = prev
63
- } catch (e) {
64
- status.textContent = `voice failed: ${e.message || e}`
65
- } finally {
66
- hearing = false; hearBtn.textContent = '🔊 Hear voice'
67
- }
68
- })
69
 
70
  function setTags(p) {
71
  tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
72
  .map((t) => el('span', { class: 'persona-tag' }, t)))
73
  }
74
- function showStats(s) {
75
- stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  }
 
 
77
 
78
  // A self-contained, paste-ready report of the last run.
79
  let lastDebug = ''
@@ -98,7 +134,6 @@ export function mountPersonaPanel(host) {
98
  copyBtn.textContent = '✓ copied'
99
  setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
100
  } catch {
101
- // Clipboard blocked (insecure context / permissions) — show it selected to copy by hand.
102
  thinkEl.textContent = text; thinkWrap.open = true
103
  const r = document.createRange(); r.selectNodeContents(thinkEl)
104
  const s = getSelection(); s.removeAllRanges(); s.addRange(r)
@@ -112,34 +147,31 @@ export function mountPersonaPanel(host) {
112
  busy = true; btn.disabled = true
113
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
114
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
115
- voiceEl.textContent = ''; hearBtn.style.display = 'none'; lastPersona = null
 
116
  if (hearing) stopPreview()
117
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
118
  let acc = ''
119
  try {
120
- status.textContent = `loading ${currentModel().label} into your browser…`
121
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
122
  status.textContent = `writing on your device with ${currentModel().label}…`
123
  await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
124
  maxTokens: MAX_TOKENS,
125
  onToken: (piece) => {
126
  acc += piece
127
- thinkEl.textContent = acc // raw view shows the model's <think> reasoning too
128
  thinkEl.scrollTop = thinkEl.scrollHeight
129
  const live = extractLivePersona(stripThink(acc))
130
  if (live.name) nameEl.textContent = live.name
131
  if (live.about) aboutEl.textContent = live.about
132
  },
133
- onStats: showStats,
134
  })
135
  try {
136
  const p = parsePersonaJson(stripThinkFinal(acc))
137
- if (p.name) nameEl.textContent = p.name
138
- aboutEl.textContent = p.about
139
- setTags(p)
140
- lastPersona = p
141
- if (p.voice) { voiceEl.textContent = `🎙 ${p.voice}`; hearBtn.style.display = '' }
142
- status.textContent = 'enlisted ✓ (generated locally)'
143
  lastDebug = buildDebug('parsed OK', acc)
144
  thinkWrap.open = false
145
  } catch (e) {
 
1
+ // Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a soldier
2
+ // (name/about/traits + a voice design + a spoken quote) on the chosen engine, lets you
3
+ // HEAR their voice say their quote, and SAVE them to a local-first barracks roster
4
+ // (personaStore) so they persist for returning visitors. Modeled on woid's agent store.
5
  import { streamChat, ensureModel, currentModel, currentModelId, getEngineId, backendLabel } from '/web/runtime.js'
6
  import { extractLivePersona } from '/web/personaStream.js'
7
  import { parsePersonaJson } from '/web/personaParse.js'
8
  import { PERSONA_SYSTEM, personaUserPrompt, stripThink, stripThinkFinal, noThink } from '/web/personaPrompts.js'
9
  import { previewVoice, stopPreview } from '/web/tts.js'
10
+ import { listPersonas, savePersona, removePersona, onRosterChange } from '/web/personaStore.js'
11
 
12
  const CLASSES = ['Warrior', 'Ranger', 'Monk', 'Assassin', 'Mage', 'Paladin', 'Cleric', 'Knight']
13
+ const MAX_TOKENS = 200 // persona JSON + a voice line + a quote
14
 
15
  function el(tag, props = {}, kids = []) {
16
  const n = document.createElement(tag)
 
29
  const stats = el('div', { class: 'persona-stats' })
30
  const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
31
  const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
32
+ const rosterEl = el('div', { class: 'persona-roster' })
33
 
34
  const nameEl = el('div', { class: 'persona-name' }, 'Your soldier')
35
  const tagsEl = el('div', { class: 'persona-tags' })
36
+ const quoteEl = el('blockquote', { class: 'persona-quote' })
37
+ const aboutEl = el('div', { class: 'persona-about' }, 'Pick a class and recruit — a small model writes their legend, designs their voice, and gives them a battle-cry.')
38
  const voiceEl = el('div', { class: 'persona-voice-desc' })
39
  const hearBtn = el('button', { class: 'persona-go persona-go-alt persona-hear', type: 'button', style: 'display:none' }, '🔊 Hear voice')
40
+ const saveBtn = el('button', { class: 'persona-go persona-save', type: 'button', style: 'display:none' }, '💾 Save to barracks')
41
  const thinkEl = el('pre', { class: 'persona-think' })
42
  const copyBtn = el('button', { class: 'persona-copy', type: 'button' }, '📋 Copy debug')
43
  const thinkWrap = el('details', { class: 'persona-think-wrap' },
 
47
  el('label', { class: 'persona-label' }, 'Class'), sel,
48
  el('label', { class: 'persona-label' }, 'Seed'), seed,
49
  btn, stats, status,
50
+ el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
51
  ])
52
+ const result = el('div', { class: 'persona-result' }, [nameEl, tagsEl, quoteEl, aboutEl, voiceEl, el('div', { class: 'persona-actions' }, [hearBtn, saveBtn]), thinkWrap])
53
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
54
 
55
+ let lastPersona = null // the persona currently shown (generated or loaded)
56
+ let savedId = null // its roster id once saved
 
57
  let hearing = false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  function setTags(p) {
60
  tagsEl.replaceChildren(...[p.specialty, p.personality, p.vibe].filter(Boolean)
61
  .map((t) => el('span', { class: 'persona-tag' }, t)))
62
  }
63
+
64
+ // Show a persona (from generation or the roster) in the result pane.
65
+ function showPersona(p, opts = {}) {
66
+ lastPersona = p
67
+ savedId = opts.savedId || null
68
+ nameEl.textContent = p.name || 'Soldier'
69
+ setTags(p)
70
+ quoteEl.textContent = p.quote ? `“${p.quote}”` : ''
71
+ aboutEl.textContent = p.about || ''
72
+ voiceEl.textContent = p.voice ? `🎙 ${p.voice}` : ''
73
+ hearBtn.style.display = p.voice ? '' : 'none'
74
+ saveBtn.style.display = ''
75
+ saveBtn.textContent = savedId ? '✓ saved' : '💾 Save to barracks'
76
+ saveBtn.disabled = !!savedId
77
+ }
78
+
79
+ // 🔊 Hear voice — design the voice from `voice` and speak the QUOTE (falls back to a
80
+ // line built from name/about if there's no quote). Networked (Qwen3-TTS).
81
+ async function hear() {
82
+ if (hearing) { stopPreview(); return }
83
+ if (!lastPersona || !lastPersona.voice) return
84
+ const line = lastPersona.quote || lastPersona.about || `${lastPersona.name} reporting for duty.`
85
+ hearing = true; hearBtn.textContent = '⏹ Stop'
86
+ const prev = status.textContent
87
+ status.textContent = 'designing the voice…'
88
+ try { await previewVoice(lastPersona.voice, line); status.textContent = prev }
89
+ catch (e) { status.textContent = `voice failed: ${e.message || e}` }
90
+ finally { hearing = false; hearBtn.textContent = '🔊 Hear voice' }
91
+ }
92
+ hearBtn.addEventListener('click', hear)
93
+
94
+ saveBtn.addEventListener('click', () => {
95
+ if (!lastPersona || savedId) return
96
+ const rec = savePersona({ ...lastPersona, unitClass: sel.value, seed: seed.value })
97
+ savedId = rec.id
98
+ saveBtn.textContent = '✓ saved'; saveBtn.disabled = true
99
+ })
100
+
101
+ // ── Barracks roster (saved soldiers) ──────────────────────────────────────
102
+ function renderRoster(personas) {
103
+ if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No soldiers saved yet.')); return }
104
+ rosterEl.replaceChildren(...personas.map((p) =>
105
+ el('div', { class: 'persona-roster-item' }, [
106
+ el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }) },
107
+ `${p.name}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
108
+ el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
109
+ ])))
110
  }
111
+ renderRoster(listPersonas())
112
+ onRosterChange(renderRoster)
113
 
114
  // A self-contained, paste-ready report of the last run.
115
  let lastDebug = ''
 
134
  copyBtn.textContent = '✓ copied'
135
  setTimeout(() => { copyBtn.textContent = '📋 Copy debug' }, 1600)
136
  } catch {
 
137
  thinkEl.textContent = text; thinkWrap.open = true
138
  const r = document.createRange(); r.selectNodeContents(thinkEl)
139
  const s = getSelection(); s.removeAllRanges(); s.addRange(r)
 
147
  busy = true; btn.disabled = true
148
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
149
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
150
+ quoteEl.textContent = ''; voiceEl.textContent = ''
151
+ hearBtn.style.display = 'none'; saveBtn.style.display = 'none'; lastPersona = null; savedId = null
152
  if (hearing) stopPreview()
153
  thinkEl.textContent = ''; thinkWrap.open = true; stats.textContent = ''
154
  let acc = ''
155
  try {
156
+ status.textContent = `loading ${currentModel().label}…`
157
  await ensureModel((frac, label) => { status.textContent = label || `downloading ${currentModel().label}… ${Math.round(frac * 100)}% (one-time)` })
158
  status.textContent = `writing on your device with ${currentModel().label}…`
159
  await streamChat(PERSONA_SYSTEM, personaUserPrompt(sel.value, seed.value) + noThink(currentModelId()), {
160
  maxTokens: MAX_TOKENS,
161
  onToken: (piece) => {
162
  acc += piece
163
+ thinkEl.textContent = acc
164
  thinkEl.scrollTop = thinkEl.scrollHeight
165
  const live = extractLivePersona(stripThink(acc))
166
  if (live.name) nameEl.textContent = live.name
167
  if (live.about) aboutEl.textContent = live.about
168
  },
169
+ onStats: (s) => { stats.textContent = `● ${s.tokPerSec} tok/s · ${s.tokens} tok${s.ttftSeconds != null ? ` · first ${s.ttftSeconds}s` : ''}` },
170
  })
171
  try {
172
  const p = parsePersonaJson(stripThinkFinal(acc))
173
+ showPersona(p)
174
+ status.textContent = 'enlisted ✓ — 💾 Save to keep'
 
 
 
 
175
  lastDebug = buildDebug('parsed OK', acc)
176
  thinkWrap.open = false
177
  } catch (e) {
web/personaParse.js CHANGED
@@ -96,6 +96,7 @@ export function parsePersonaJson(raw) {
96
  const specialty = trimTag(parsed.specialty ?? parsed.role ?? parsed.job ?? null);
97
  const personality = trimTag(parsed.personality ?? parsed.personalityTag ?? null);
98
  const voice = (typeof parsed.voice === "string" ? parsed.voice.trim() : "").slice(0, 300);
 
99
 
100
- return { name: name || null, about, avatar_hint, vibe, specialty, personality, voice };
101
  }
 
96
  const specialty = trimTag(parsed.specialty ?? parsed.role ?? parsed.job ?? null);
97
  const personality = trimTag(parsed.personality ?? parsed.personalityTag ?? null);
98
  const voice = (typeof parsed.voice === "string" ? parsed.voice.trim() : "").slice(0, 300);
99
+ const quote = (typeof parsed.quote === "string" ? parsed.quote.trim() : "").slice(0, 200);
100
 
101
+ return { name: name || null, about, avatar_hint, vibe, specialty, personality, voice, quote };
102
  }
web/personaPrompts.js CHANGED
@@ -12,7 +12,9 @@ export const PERSONA_SYSTEM =
12
  ' "vibe": a 1-3 word vibe,\n' +
13
  ' "voice": one sentence describing how they SOUND for a text-to-speech voice — gender, ' +
14
  'age, pitch, accent, texture, pace and emotion (e.g. "a gravelly, battle-worn male ' +
15
- 'baritone, slow and weary, with a faint highland accent").\n' +
 
 
16
  'Output strictly valid JSON. No preamble, no code fences, no commentary.'
17
 
18
  export const DIARY_SYSTEM =
 
12
  ' "vibe": a 1-3 word vibe,\n' +
13
  ' "voice": one sentence describing how they SOUND for a text-to-speech voice — gender, ' +
14
  'age, pitch, accent, texture, pace and emotion (e.g. "a gravelly, battle-worn male ' +
15
+ 'baritone, slow and weary, with a faint highland accent"),\n' +
16
+ ' "quote": one short punchy line they say aloud — a battle-cry or wry remark, ' +
17
+ 'first person, under 15 words.\n' +
18
  'Output strictly valid JSON. No preamble, no code fences, no commentary.'
19
 
20
  export const DIARY_SYSTEM =
web/personaStore.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Local-first roster of saved soldiers (personas-as-agents). Modeled on woid's Shelter
2
+ // store: a single JSON blob in localStorage, simple CRUD, change listeners, and a
3
+ // pluggable `sync` hook so a backend can push/pull later WITHOUT changing callers.
4
+ // Voice is stored as the design TEXT + quote (re-synthesized on replay) — the audio
5
+ // blob and cross-device sync are the backend's job (see the plan). Future persona data
6
+ // (stats, avatar, xp, relationships) just adds fields to the record.
7
+ const KEY = 'tinyarmy.roster.v1'
8
+
9
+ const listeners = new Set()
10
+ let _sync = null // optional { push(records), pull() } — wired to a backend later
11
+
12
+ function read() {
13
+ try { const d = JSON.parse(localStorage.getItem(KEY) || '{}'); return Array.isArray(d.personas) ? d : { personas: [] } }
14
+ catch { return { personas: [] } }
15
+ }
16
+ function write(d) {
17
+ try { localStorage.setItem(KEY, JSON.stringify(d)) } catch { /* quota / disabled */ }
18
+ for (const fn of listeners) { try { fn(d.personas) } catch { /* ignore */ } }
19
+ if (_sync && _sync.push) { try { _sync.push(d.personas) } catch { /* best-effort */ } }
20
+ }
21
+
22
+ const newId = () => 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
23
+
24
+ export function listPersonas() { return read().personas }
25
+ export function getPersona(id) { return read().personas.find((p) => p.id === id) || null }
26
+ export function onRosterChange(fn) { listeners.add(fn); return () => listeners.delete(fn) }
27
+ export function setSync(sync) { _sync = sync }
28
+
29
+ // Insert or update. Returns the stored record (with id + timestamps).
30
+ export function savePersona(p) {
31
+ const d = read()
32
+ const now = Date.now()
33
+ const id = p.id || newId()
34
+ const rec = {
35
+ id,
36
+ name: p.name || 'Unnamed soldier',
37
+ unitClass: p.unitClass || '',
38
+ about: p.about || '',
39
+ quote: p.quote || '',
40
+ voice: p.voice || '',
41
+ specialty: p.specialty || '',
42
+ personality: p.personality || '',
43
+ vibe: p.vibe || '',
44
+ seed: p.seed || '',
45
+ createdAt: now,
46
+ updatedAt: now,
47
+ }
48
+ const i = d.personas.findIndex((x) => x.id === id)
49
+ if (i >= 0) { rec.createdAt = d.personas[i].createdAt; d.personas[i] = rec }
50
+ else { d.personas.unshift(rec) }
51
+ write(d)
52
+ return rec
53
+ }
54
+
55
+ export function removePersona(id) {
56
+ const d = read()
57
+ const next = d.personas.filter((x) => x.id !== id)
58
+ if (next.length !== d.personas.length) write({ personas: next })
59
+ }
web/shell/persona.css CHANGED
@@ -67,7 +67,33 @@
67
  max-width: 60ch; margin-top: 14px; font-style: italic;
68
  }
69
  .persona-voice-desc:empty { display: none; }
70
- .persona-hear { display: inline-block; margin-top: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  /* ── Model picker + cache controls ─────────────────────────────────────────── */
73
  .model-bar { display: flex; flex-direction: column; gap: 4px; padding-bottom: 10px; margin-bottom: 6px; border-bottom: 1px dashed var(--p-ink); }
 
67
  max-width: 60ch; margin-top: 14px; font-style: italic;
68
  }
69
  .persona-voice-desc:empty { display: none; }
70
+ .persona-quote {
71
+ margin: 14px 0 0; padding: 8px 0 8px 16px; border-left: 3px solid var(--p-transmit);
72
+ font-family: 'Fraunces', Georgia, serif; font-size: 20px; font-style: italic;
73
+ line-height: 1.35; color: var(--p-ink); max-width: 56ch;
74
+ }
75
+ .persona-quote:empty { display: none; }
76
+ .persona-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
77
+ .persona-hear, .persona-save { display: inline-block; margin-top: 0; }
78
+
79
+ /* ── Barracks roster (saved soldiers) ──────────────────────────────────────── */
80
+ .persona-roster-label { margin-top: 18px; }
81
+ .persona-roster { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; max-height: 230px; overflow-y: auto; }
82
+ .persona-roster-empty { font-family: var(--p-mono); font-size: 10px; color: var(--p-muted); padding: 4px 0; }
83
+ .persona-roster-item { display: flex; align-items: stretch; gap: 4px; }
84
+ .persona-roster-name {
85
+ flex: 1; text-align: left; cursor: pointer;
86
+ font-family: var(--p-sans) !important; font-size: 13px !important; color: var(--p-ink) !important;
87
+ background: var(--p-card) !important; border: 1.5px solid var(--p-ink) !important; border-radius: 0 !important;
88
+ padding: 7px 9px !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
89
+ }
90
+ .persona-roster-name:hover { background: var(--p-paper-2) !important; }
91
+ .persona-roster-x {
92
+ cursor: pointer; flex-shrink: 0; font-size: 11px !important;
93
+ color: var(--p-transmit) !important; background: var(--p-card) !important;
94
+ border: 1.5px solid var(--p-transmit) !important; border-radius: 0 !important; padding: 0 8px !important;
95
+ }
96
+ .persona-roster-x:hover { background: var(--p-transmit) !important; }
97
 
98
  /* ── Model picker + cache controls ─────────────────────────────────────────── */
99
  .model-bar { display: flex; flex-direction: column; gap: 4px; padding-bottom: 10px; margin-bottom: 6px; border-bottom: 1px dashed var(--p-ink); }