polats Claude Opus 4.8 (1M context) commited on
Commit
29474fc
·
1 Parent(s): 9f5e779

Personas → heroes: shield icon, hero terminology, empty state, active highlight

Browse files

- Nav icon for Personas: 🪖 (contemporary helmet) → 🛡 (fantasy shield).
- "Recruit a soldier" → "Recruit hero"; all "soldier" copy → "hero" ("No heroes saved
yet", prompts, store defaults, diary).
- Persona page hides the hero fields until a hero is generated or picked (empty state
"Recruit a hero, or pick one from the barracks").
- The selected hero is highlighted in the barracks roster (ink fill + red accent bar).
Verified: icon, empty/shown toggle, active highlight, terminology.

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

web/diaryPanel.js CHANGED
@@ -80,7 +80,7 @@ export function mountDiaryPanel(host) {
80
  // Ensure the TTS model is loaded (showing progress), then return a fresh narrator.
81
  async function makeReadyNarrator() {
82
  // For Qwen3-TTS's "persona voice", design a voice from this unit's traits.
83
- setVoiceDescription(`A war-weary soldier's voice — ${(traits.value || 'battle-hardened').trim()}.`)
84
  ttsStatus.textContent = 'loading voice…'
85
  await ensureTts((frac) => { ttsStatus.textContent = `downloading voice… ${Math.round(frac * 100)}% (one-time)` })
86
  ttsStatus.textContent = 'reading on your device…'
@@ -116,7 +116,7 @@ export function mountDiaryPanel(host) {
116
  busy = true; btn.disabled = true; stats.textContent = ''
117
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
118
  stopNarration()
119
- const header = `— Diary of ${(unit.value || 'a nameless soldier').trim()} —\n\n`
120
  out.textContent = header
121
  lastBody = ''
122
 
 
80
  // Ensure the TTS model is loaded (showing progress), then return a fresh narrator.
81
  async function makeReadyNarrator() {
82
  // For Qwen3-TTS's "persona voice", design a voice from this unit's traits.
83
+ setVoiceDescription(`A war-weary hero's voice — ${(traits.value || 'battle-hardened').trim()}.`)
84
  ttsStatus.textContent = 'loading voice…'
85
  await ensureTts((frac) => { ttsStatus.textContent = `downloading voice… ${Math.round(frac * 100)}% (one-time)` })
86
  ttsStatus.textContent = 'reading on your device…'
 
116
  busy = true; btn.disabled = true; stats.textContent = ''
117
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
118
  stopNarration()
119
+ const header = `— Diary of ${(unit.value || 'a nameless hero').trim()} —\n\n`
120
  out.textContent = header
121
  lastBody = ''
122
 
web/personaPanel.js CHANGED
@@ -1,4 +1,4 @@
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), lets you CREATE a voice file of
3
  // them saying their quote (Qwen3-TTS) and REPLAY it, edit every field inline (auto-saved),
4
  // and keeps everyone in a local-first barracks roster (personaStore) so they persist for
@@ -29,7 +29,7 @@ export function mountPersonaPanel(host) {
29
  const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
30
  const stats = el('div', { class: 'persona-stats' })
31
  const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
32
- const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit a soldier')
33
  const rosterEl = el('div', { class: 'persona-roster' })
34
 
35
  const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
@@ -56,22 +56,32 @@ export function mountPersonaPanel(host) {
56
  btn, stats, status,
57
  el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
58
  ])
59
- const result = el('div', { class: 'persona-result' }, [
 
60
  nameEl, tagsEl,
61
  secHead('About'), aboutEl,
62
  secHead('Quote', playBtn), quoteEl,
63
  secHead('Voice design'), voiceEl,
64
- thinkWrap,
65
  ])
 
66
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
67
 
68
  let lastPersona = null // the persona currently shown
69
  let savedId = null // its roster id (set the moment it's shown — always saved)
70
  let hasVoice = false // a cached voice file exists for this persona
71
  let working = false
 
 
 
 
 
 
 
 
 
72
 
73
  // The line the voice actually says (quote, else about, else a fallback).
74
- const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A soldier'} reporting for duty.`
75
  // Cached audio is stale if the line or the voice design changed since it was made.
76
  const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
77
  // Badge when there's a persona but no current voice (none yet, or it went stale).
@@ -121,7 +131,7 @@ export function mountPersonaPanel(host) {
121
  quoteEl.textContent = p.quote || ''
122
  voiceEl.textContent = p.voice || ''
123
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
124
- updateVoiceUI()
125
  }
126
 
127
  // ▶ The one voice button: if the cached voice is current, just replay it. If there's
@@ -161,13 +171,13 @@ export function mountPersonaPanel(host) {
161
  }
162
  playBtn.addEventListener('click', play)
163
 
164
- // ── Barracks roster (saved soldiers) ──────────────────────────────────────
165
  function renderRoster(personas) {
166
- if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No soldiers saved yet.')); return }
167
  rosterEl.replaceChildren(...personas.map((p) =>
168
  el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
169
  el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
170
- `${p.name || 'Soldier'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
171
  el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
172
  ])))
173
  }
@@ -200,10 +210,9 @@ export function mountPersonaPanel(host) {
200
  }
201
  })
202
 
203
- let busy = false
204
  async function generate() {
205
  if (busy) return
206
- busy = true; btn.disabled = true
207
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
208
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
209
  quoteEl.textContent = ''; voiceEl.textContent = ''
@@ -240,7 +249,7 @@ export function mountPersonaPanel(host) {
240
  } catch (e) {
241
  status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
242
  lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc); thinkWrap.open = true
243
- } finally { busy = false; btn.disabled = false }
244
  }
245
  btn.addEventListener('click', generate)
246
  }
 
1
+ // Tiny Army persona panel — mounted by tiny.js into #persona-stage. Recruits a hero
2
  // (name/about/traits + a voice design + a spoken quote), lets you CREATE a voice file of
3
  // them saying their quote (Qwen3-TTS) and REPLAY it, edit every field inline (auto-saved),
4
  // and keeps everyone in a local-first barracks roster (personaStore) so they persist for
 
29
  const seed = el('input', { class: 'persona-input', type: 'text', placeholder: 'a word, a vibe… (optional)' })
30
  const stats = el('div', { class: 'persona-stats' })
31
  const status = el('div', { class: 'persona-status' }, 'Runs on your device — no cloud.')
32
+ const btn = el('button', { class: 'persona-go', type: 'button' }, '⚔ Recruit hero')
33
  const rosterEl = el('div', { class: 'persona-roster' })
34
 
35
  const nameEl = el('div', { class: 'persona-name persona-edit', 'data-ph': 'Name' })
 
56
  btn, stats, status,
57
  el('label', { class: 'persona-label persona-roster-label' }, 'Barracks (saved)'), rosterEl,
58
  ])
59
+ const emptyEl = el('div', { class: 'persona-empty' }, 'Recruit a hero, or pick one from the barracks.')
60
+ const bodyEl = el('div', { class: 'persona-body' }, [
61
  nameEl, tagsEl,
62
  secHead('About'), aboutEl,
63
  secHead('Quote', playBtn), quoteEl,
64
  secHead('Voice design'), voiceEl,
 
65
  ])
66
+ const result = el('div', { class: 'persona-result' }, [emptyEl, bodyEl, thinkWrap])
67
  host.appendChild(el('div', { class: 'persona-view' }, [controls, result]))
68
 
69
  let lastPersona = null // the persona currently shown
70
  let savedId = null // its roster id (set the moment it's shown — always saved)
71
  let hasVoice = false // a cached voice file exists for this persona
72
  let working = false
73
+ let busy = false
74
+
75
+ // Hide the hero fields until a hero is generated or picked from the barracks.
76
+ function refreshVisibility() {
77
+ const show = !!lastPersona || busy
78
+ bodyEl.style.display = show ? '' : 'none'
79
+ emptyEl.style.display = show ? 'none' : ''
80
+ }
81
+ refreshVisibility()
82
 
83
  // The line the voice actually says (quote, else about, else a fallback).
84
+ const lineFor = (p) => (p.quote || '').trim() || (p.about || '').trim() || `${p.name || 'A hero'} reporting for duty.`
85
  // Cached audio is stale if the line or the voice design changed since it was made.
86
  const isDirty = () => hasVoice && lastPersona && (lineFor(lastPersona) !== lastPersona.voiceQuote || (lastPersona.voice || '') !== (lastPersona.voiceDesignUsed || ''))
87
  // Badge when there's a persona but no current voice (none yet, or it went stale).
 
131
  quoteEl.textContent = p.quote || ''
132
  voiceEl.textContent = p.voice || ''
133
  hasVoice = savedId ? !!(await getAudio(savedId)) : false
134
+ updateVoiceUI(); refreshVisibility()
135
  }
136
 
137
  // ▶ The one voice button: if the cached voice is current, just replay it. If there's
 
171
  }
172
  playBtn.addEventListener('click', play)
173
 
174
+ // ── Barracks roster (saved heroes) ──────────────────────────────────────
175
  function renderRoster(personas) {
176
+ if (!personas.length) { rosterEl.replaceChildren(el('div', { class: 'persona-roster-empty' }, 'No heroes saved yet.')); return }
177
  rosterEl.replaceChildren(...personas.map((p) =>
178
  el('div', { class: 'persona-roster-item' + (p.id === savedId ? ' active' : '') }, [
179
  el('button', { class: 'persona-roster-name', type: 'button', title: 'View', onclick: () => showPersona(p, { savedId: p.id }).then(() => renderRoster(listPersonas())) },
180
+ `${p.name || 'Hero'}${p.unitClass ? ` · ${p.unitClass}` : ''}`),
181
  el('button', { class: 'persona-roster-x', type: 'button', title: 'Remove', onclick: () => removePersona(p.id) }, '🗑'),
182
  ])))
183
  }
 
210
  }
211
  })
212
 
 
213
  async function generate() {
214
  if (busy) return
215
+ busy = true; btn.disabled = true; refreshVisibility()
216
  if (window.innerWidth <= 768) result.scrollIntoView({ behavior: 'smooth', block: 'start' })
217
  nameEl.textContent = '…'; aboutEl.textContent = ''; tagsEl.replaceChildren()
218
  quoteEl.textContent = ''; voiceEl.textContent = ''
 
249
  } catch (e) {
250
  status.textContent = `couldn't run the local model: ${e.message || e} · 📋 Copy debug`
251
  lastDebug = buildDebug('EXCEPTION: ' + (e.message || e) + (e.stack ? '\n' + e.stack : ''), acc); thinkWrap.open = true
252
+ } finally { busy = false; btn.disabled = false; refreshVisibility() }
253
  }
254
  btn.addEventListener('click', generate)
255
  }
web/personaPrompts.js CHANGED
@@ -2,10 +2,10 @@
2
  // in-browser path). War-legend tone, not woid's.
3
 
4
  export const PERSONA_SYSTEM =
5
- 'You invent tiny soldiers for a fantasy auto-battler called Tiny Army, where every ' +
6
  'fighter writes its own legend. Given a class and an optional seed, return ONE JSON ' +
7
  'object and NOTHING else, with exactly these keys:\n' +
8
- ' "name": a short evocative soldier name (2-4 words),\n' +
9
  ' "about": 1-2 short sentences of backstory (about 25 words) in a heroic, wry war-legend tone,\n' +
10
  ' "specialty": a 1-3 word combat specialty,\n' +
11
  ' "personality": a 1-3 word personality tag,\n' +
@@ -18,18 +18,18 @@ export const PERSONA_SYSTEM =
18
  'Output strictly valid JSON. No preamble, no code fences, no commentary.'
19
 
20
  export const DIARY_SYSTEM =
21
- 'You are a tiny soldier in the auto-battler Tiny Army, writing a short first-person ' +
22
  'war-diary entry. Given your name and traits, write just 1-2 vivid sentences (about ' +
23
  '60 words, no more) in first person about a day on the battlefield — heroic, grounded, ' +
24
  'a touch of dark humor. Prose only: no headings, no lists, no preamble. Be brief.'
25
 
26
  export function personaUserPrompt(unitClass = '', seed = '') {
27
  const s = seed && seed.trim() ? ` Seed inspiration: "${seed.trim()}".` : ''
28
- return `Class: ${(unitClass || 'soldier').trim()}.${s} Return the JSON object now.`
29
  }
30
 
31
  export function diaryUserPrompt(unit = '', traits = '') {
32
- const u = (unit || 'a nameless soldier').trim()
33
  const t = (traits || 'untested').trim()
34
  return `Name: ${u}. Traits: ${t}. Write the diary entry.`
35
  }
 
2
  // in-browser path). War-legend tone, not woid's.
3
 
4
  export const PERSONA_SYSTEM =
5
+ 'You invent tiny heroes for a fantasy auto-battler called Tiny Army, where every ' +
6
  'fighter writes its own legend. Given a class and an optional seed, return ONE JSON ' +
7
  'object and NOTHING else, with exactly these keys:\n' +
8
+ ' "name": a short evocative hero name (2-4 words),\n' +
9
  ' "about": 1-2 short sentences of backstory (about 25 words) in a heroic, wry war-legend tone,\n' +
10
  ' "specialty": a 1-3 word combat specialty,\n' +
11
  ' "personality": a 1-3 word personality tag,\n' +
 
18
  'Output strictly valid JSON. No preamble, no code fences, no commentary.'
19
 
20
  export const DIARY_SYSTEM =
21
+ 'You are a tiny hero in the auto-battler Tiny Army, writing a short first-person ' +
22
  'war-diary entry. Given your name and traits, write just 1-2 vivid sentences (about ' +
23
  '60 words, no more) in first person about a day on the battlefield — heroic, grounded, ' +
24
  'a touch of dark humor. Prose only: no headings, no lists, no preamble. Be brief.'
25
 
26
  export function personaUserPrompt(unitClass = '', seed = '') {
27
  const s = seed && seed.trim() ? ` Seed inspiration: "${seed.trim()}".` : ''
28
+ return `Class: ${(unitClass || 'hero').trim()}.${s} Return the JSON object now.`
29
  }
30
 
31
  export function diaryUserPrompt(unit = '', traits = '') {
32
+ const u = (unit || 'a nameless hero').trim()
33
  const t = (traits || 'untested').trim()
34
  return `Name: ${u}. Traits: ${t}. Write the diary entry.`
35
  }
web/personaStore.js CHANGED
@@ -1,4 +1,4 @@
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
@@ -33,7 +33,7 @@ export function savePersona(p) {
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 || '',
 
1
+ // Local-first roster of saved heroes (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
 
33
  const id = p.id || newId()
34
  const rec = {
35
  id,
36
+ name: p.name || 'Unnamed hero',
37
  unitClass: p.unitClass || '',
38
  about: p.about || '',
39
  quote: p.quote || '',
web/shell/nav.json CHANGED
@@ -25,7 +25,7 @@
25
  "title": "Barracks",
26
  "items": [
27
  { "label": "War Diaries", "icon": "📓", "space": "Barracks" },
28
- { "label": "Personas", "icon": "🪖", "space": "Personas" }
29
  ]
30
  },
31
  {
 
25
  "title": "Barracks",
26
  "items": [
27
  { "label": "War Diaries", "icon": "📓", "space": "Barracks" },
28
+ { "label": "Personas", "icon": "🛡", "space": "Personas" }
29
  ]
30
  },
31
  {
web/shell/persona.css CHANGED
@@ -109,7 +109,13 @@
109
  .persona-edit:focus { background: var(--p-card); box-shadow: 0 0 0 1.5px var(--p-transmit); }
110
  .persona-edit:empty::before { content: attr(data-ph); color: var(--p-muted); opacity: .6; font-style: italic; }
111
 
112
- /* ── Barracks roster (saved soldiers) ──────────────────────────────────────── */
 
 
 
 
 
 
113
  .persona-roster-label { margin-top: 18px; }
114
  .persona-roster { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; max-height: 230px; overflow-y: auto; }
115
  .persona-roster-empty { font-family: var(--p-mono); font-size: 10px; color: var(--p-muted); padding: 4px 0; }
@@ -121,6 +127,11 @@
121
  padding: 7px 9px !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
122
  }
123
  .persona-roster-name:hover { background: var(--p-paper-2) !important; }
 
 
 
 
 
124
  .persona-roster-x {
125
  cursor: pointer; flex-shrink: 0; font-size: 11px !important;
126
  color: var(--p-transmit) !important; background: var(--p-card) !important;
 
109
  .persona-edit:focus { background: var(--p-card); box-shadow: 0 0 0 1.5px var(--p-transmit); }
110
  .persona-edit:empty::before { content: attr(data-ph); color: var(--p-muted); opacity: .6; font-style: italic; }
111
 
112
+ /* Empty state shown until a hero is recruited or picked from the barracks. */
113
+ .persona-empty {
114
+ font-family: var(--p-mono); font-size: 12px; letter-spacing: .04em; color: var(--p-muted);
115
+ padding: 28px 0; max-width: 50ch;
116
+ }
117
+
118
+ /* ── Barracks roster (saved heroes) ────────────────────────────────────────── */
119
  .persona-roster-label { margin-top: 18px; }
120
  .persona-roster { display: flex; flex-direction: column; gap: 4px; margin-top: 4px; max-height: 230px; overflow-y: auto; }
121
  .persona-roster-empty { font-family: var(--p-mono); font-size: 10px; color: var(--p-muted); padding: 4px 0; }
 
127
  padding: 7px 9px !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
128
  }
129
  .persona-roster-name:hover { background: var(--p-paper-2) !important; }
130
+ /* The selected hero is highlighted in the barracks. */
131
+ .persona-roster-item.active .persona-roster-name {
132
+ background: var(--p-ink) !important; color: var(--p-paper) !important;
133
+ border-color: var(--p-ink) !important; box-shadow: inset 4px 0 0 var(--p-transmit); font-weight: 700;
134
+ }
135
  .persona-roster-x {
136
  cursor: pointer; flex-shrink: 0; font-size: 11px !important;
137
  color: var(--p-transmit) !important; background: var(--p-card) !important;