@kawaijs/renderer-dom 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/renderer.js CHANGED
@@ -19,30 +19,121 @@ export function defaultAssetResolver(path, type) {
19
19
  }
20
20
  return path;
21
21
  }
22
+ /**
23
+ * Preload all background and character assets mentioned in a story to eliminate visual flashes.
24
+ */
25
+ export function preloadStoryAssets(story, assetResolver = defaultAssetResolver) {
26
+ if (typeof window === 'undefined')
27
+ return;
28
+ const bgSet = new Set();
29
+ const charSet = new Set();
30
+ for (const label of Object.values(story.labels)) {
31
+ for (const inst of label) {
32
+ if (inst.type === 'scene' && inst.background) {
33
+ const clean = inst.background.replace(/^bg[\s_]+/i, '').trim();
34
+ bgSet.add(clean);
35
+ }
36
+ else if (inst.type === 'show') {
37
+ const key = inst.expression ? `${inst.character}/${inst.expression}` : inst.character;
38
+ charSet.add(key);
39
+ }
40
+ }
41
+ }
42
+ for (const bg of bgSet) {
43
+ const img = new Image();
44
+ img.src = assetResolver(bg, 'background');
45
+ }
46
+ for (const char of charSet) {
47
+ const img = new Image();
48
+ img.src = assetResolver(char, 'character');
49
+ }
50
+ }
51
+ export const SVG_ICONS = {
52
+ back: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>',
53
+ history: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>',
54
+ save: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
55
+ load: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
56
+ auto: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
57
+ skip: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>',
58
+ settings: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
59
+ volumeOn: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>',
60
+ volumeMute: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>',
61
+ trash: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
62
+ close: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
63
+ arrowDown: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
64
+ replay: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
65
+ };
66
+ /**
67
+ * Parses inline rich text markup: {b}...{/b}, {i}...{/i}, {color=#hex}...{/color}, {size=1.2em}...{/size}
68
+ */
69
+ export function formatRichText(raw) {
70
+ let formatted = raw
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;');
74
+ // {b}...{/b}
75
+ formatted = formatted.replace(/\{b\}(.*?)\{\/b\}/gi, '<strong class="kawa-bold">$1</strong>');
76
+ // {i}...{/i}
77
+ formatted = formatted.replace(/\{i\}(.*?)\{\/i\}/gi, '<em class="kawa-italic">$1</em>');
78
+ // {color=#hex}...{/color}
79
+ formatted = formatted.replace(/\{color=([^}]+)\}(.*?)\{\/color\}/gi, '<span style="color:$1">$2</span>');
80
+ // {size=1.2em}...{/size}
81
+ formatted = formatted.replace(/\{size=([^}]+)\}(.*?)\{\/size\}/gi, '<span style="font-size:$1">$2</span>');
82
+ return formatted;
83
+ }
22
84
  export class DOMRenderer {
23
85
  vm;
24
86
  container;
25
87
  typewriterSpeed;
88
+ autoDelayMs;
26
89
  assetResolver;
27
90
  rootEl;
28
91
  stageEl;
29
92
  backgroundEl;
93
+ bgLayerA;
94
+ bgLayerB;
95
+ activeBgLayer = 'A';
96
+ currentBgUrl = '';
30
97
  charactersEl;
98
+ activeCharacters = new Map();
99
+ modeBadgeEl;
31
100
  dialogueBoxEl;
32
101
  speakerTagEl;
33
102
  dialogueTextEl;
34
103
  choiceContainerEl;
35
104
  backBtnEl;
105
+ autoBtnEl;
106
+ skipBtnEl;
36
107
  currentTypewriterInterval = null;
37
108
  isTypewriting = false;
38
109
  fullCurrentText = '';
39
110
  isChoicePending = false;
111
+ isAutoMode = false;
112
+ isSkipMode = false;
113
+ autoTimer = null;
114
+ skipInterval = null;
40
115
  unsubscribeVMState;
116
+ boundKeyHandler;
41
117
  constructor(vm, options) {
42
118
  this.vm = vm;
43
119
  this.container = options.container;
44
120
  this.typewriterSpeed = options.typewriterSpeed ?? 20;
121
+ this.autoDelayMs = options.autoDelayMs ?? 1800;
45
122
  this.assetResolver = options.assetResolver ?? defaultAssetResolver;
123
+ // Load saved settings if present
124
+ if (typeof localStorage !== 'undefined') {
125
+ try {
126
+ const saved = localStorage.getItem('kawaijs_settings');
127
+ if (saved) {
128
+ const parsed = JSON.parse(saved);
129
+ if (typeof parsed.typewriterSpeed === 'number')
130
+ this.typewriterSpeed = parsed.typewriterSpeed;
131
+ if (typeof parsed.autoDelayMs === 'number')
132
+ this.autoDelayMs = parsed.autoDelayMs;
133
+ }
134
+ }
135
+ catch { }
136
+ }
46
137
  this.buildDOM();
47
138
  this.bindEvents();
48
139
  this.unsubscribeVMState = this.vm.onStateChange((state) => {
@@ -58,15 +149,25 @@ export class DOMRenderer {
58
149
  if (this.currentTypewriterInterval) {
59
150
  clearInterval(this.currentTypewriterInterval);
60
151
  }
152
+ if (this.autoTimer) {
153
+ clearTimeout(this.autoTimer);
154
+ }
155
+ if (this.skipInterval) {
156
+ clearInterval(this.skipInterval);
157
+ }
158
+ if (this.boundKeyHandler && typeof window !== 'undefined') {
159
+ window.removeEventListener('keydown', this.boundKeyHandler);
160
+ }
61
161
  this.container.innerHTML = '';
62
162
  }
63
- shakeScreen() {
163
+ shakeScreen(type = 'shake') {
64
164
  if (!this.stageEl)
65
165
  return;
66
- this.stageEl.classList.remove('kawa-shake');
67
- // Trigger reflow to restart animation
166
+ this.stageEl.classList.remove('kawa-shake', 'kawa-vpunch', 'kawa-hpunch');
167
+ // Trigger reflow
68
168
  void this.stageEl.offsetWidth;
69
- this.stageEl.classList.add('kawa-shake');
169
+ const cls = type === 'vpunch' ? 'kawa-vpunch' : type === 'hpunch' ? 'kawa-hpunch' : 'kawa-shake';
170
+ this.stageEl.classList.add(cls);
70
171
  }
71
172
  flashScreen() {
72
173
  if (!this.stageEl)
@@ -79,71 +180,214 @@ export class DOMRenderer {
79
180
  this.stageEl.appendChild(flash);
80
181
  setTimeout(() => flash.remove(), 600);
81
182
  }
183
+ toggleAutoMode(force) {
184
+ this.isAutoMode = force !== undefined ? force : !this.isAutoMode;
185
+ if (this.isAutoMode && this.isSkipMode) {
186
+ this.toggleSkipMode(false);
187
+ }
188
+ this.updateModeUI();
189
+ if (this.isAutoMode && !this.isTypewriting) {
190
+ this.scheduleAutoAdvance();
191
+ }
192
+ else if (!this.isAutoMode && this.autoTimer) {
193
+ clearTimeout(this.autoTimer);
194
+ this.autoTimer = null;
195
+ }
196
+ }
197
+ toggleSkipMode(force) {
198
+ this.isSkipMode = force !== undefined ? force : !this.isSkipMode;
199
+ if (this.isSkipMode && this.isAutoMode) {
200
+ this.toggleAutoMode(false);
201
+ }
202
+ this.updateModeUI();
203
+ if (this.isSkipMode) {
204
+ if (this.autoTimer) {
205
+ clearTimeout(this.autoTimer);
206
+ this.autoTimer = null;
207
+ }
208
+ if (!this.skipInterval) {
209
+ this.skipInterval = window.setInterval(() => {
210
+ const state = this.vm.getState();
211
+ if (state.isFinished || (state.choices && state.choices.length > 0)) {
212
+ this.toggleSkipMode(false);
213
+ return;
214
+ }
215
+ if (this.isTypewriting) {
216
+ this.finishTypewriter();
217
+ }
218
+ this.vm.next();
219
+ }, 55);
220
+ }
221
+ }
222
+ else {
223
+ if (this.skipInterval) {
224
+ clearInterval(this.skipInterval);
225
+ this.skipInterval = null;
226
+ }
227
+ }
228
+ }
229
+ updateModeUI() {
230
+ if (this.autoBtnEl) {
231
+ this.autoBtnEl.classList.toggle('active', this.isAutoMode);
232
+ }
233
+ if (this.skipBtnEl) {
234
+ this.skipBtnEl.classList.toggle('active', this.isSkipMode);
235
+ }
236
+ if (this.modeBadgeEl) {
237
+ if (this.isSkipMode) {
238
+ this.modeBadgeEl.innerHTML = `${SVG_ICONS.skip} SKIP`;
239
+ this.modeBadgeEl.style.display = 'inline-flex';
240
+ }
241
+ else if (this.isAutoMode) {
242
+ this.modeBadgeEl.innerHTML = `${SVG_ICONS.auto} AUTO`;
243
+ this.modeBadgeEl.style.display = 'inline-flex';
244
+ }
245
+ else {
246
+ this.modeBadgeEl.style.display = 'none';
247
+ }
248
+ }
249
+ }
250
+ scheduleAutoAdvance() {
251
+ if (this.autoTimer) {
252
+ clearTimeout(this.autoTimer);
253
+ this.autoTimer = null;
254
+ }
255
+ if (!this.isAutoMode)
256
+ return;
257
+ const state = this.vm.getState();
258
+ if (state.isFinished || (state.choices && state.choices.length > 0)) {
259
+ return;
260
+ }
261
+ const duration = Math.max(800, this.autoDelayMs + (state.dialogue?.text.length ?? 0) * 15);
262
+ this.autoTimer = window.setTimeout(() => {
263
+ if (this.isAutoMode) {
264
+ const cur = this.vm.getState();
265
+ if (!cur.isFinished && (!cur.choices || cur.choices.length === 0)) {
266
+ this.vm.next();
267
+ }
268
+ }
269
+ }, duration);
270
+ }
82
271
  buildDOM() {
83
272
  this.container.innerHTML = '';
84
273
  this.rootEl = document.createElement('div');
85
274
  this.rootEl.className = 'kawa-root';
86
275
  this.stageEl = document.createElement('div');
87
276
  this.stageEl.className = 'kawa-stage';
277
+ this.stageEl.setAttribute('role', 'main');
278
+ this.stageEl.setAttribute('aria-label', 'Visual Novel Stage');
88
279
  this.backgroundEl = document.createElement('div');
89
280
  this.backgroundEl.className = 'kawa-background';
281
+ this.backgroundEl.setAttribute('aria-hidden', 'true');
282
+ this.bgLayerA = document.createElement('div');
283
+ this.bgLayerA.className = 'kawa-bg-layer active';
284
+ this.bgLayerB = document.createElement('div');
285
+ this.bgLayerB.className = 'kawa-bg-layer';
286
+ this.backgroundEl.appendChild(this.bgLayerA);
287
+ this.backgroundEl.appendChild(this.bgLayerB);
90
288
  this.charactersEl = document.createElement('div');
91
289
  this.charactersEl.className = 'kawa-characters kawa-sprites';
290
+ this.charactersEl.setAttribute('aria-hidden', 'true');
291
+ // Mode Badge (Auto / Skip indicator)
292
+ this.modeBadgeEl = document.createElement('div');
293
+ this.modeBadgeEl.className = 'kawa-mode-badge';
294
+ this.modeBadgeEl.style.display = 'none';
92
295
  const uiLayerEl = document.createElement('div');
93
296
  uiLayerEl.className = 'kawa-ui-layer';
94
297
  // Choice Container
95
298
  this.choiceContainerEl = document.createElement('div');
96
299
  this.choiceContainerEl.className = 'kawa-choice-container kawa-choices';
300
+ this.choiceContainerEl.setAttribute('role', 'group');
301
+ this.choiceContainerEl.setAttribute('aria-label', 'Choices');
97
302
  this.choiceContainerEl.style.display = 'none';
98
303
  // Dialogue Box
99
304
  this.dialogueBoxEl = document.createElement('div');
100
305
  this.dialogueBoxEl.className = 'kawa-dialogue-box kawa-dialogue';
306
+ this.dialogueBoxEl.setAttribute('role', 'region');
307
+ this.dialogueBoxEl.setAttribute('aria-label', 'Dialogue');
308
+ this.dialogueBoxEl.setAttribute('tabindex', '0');
101
309
  this.speakerTagEl = document.createElement('div');
102
310
  this.speakerTagEl.className = 'kawa-speaker-tag kawa-speaker';
103
311
  this.dialogueTextEl = document.createElement('div');
104
312
  this.dialogueTextEl.className = 'kawa-dialogue-text kawa-text';
313
+ this.dialogueTextEl.setAttribute('aria-live', 'polite');
105
314
  const indicatorEl = document.createElement('div');
106
315
  indicatorEl.className = 'kawa-continue-indicator';
107
- indicatorEl.innerHTML = '&#9660;';
316
+ indicatorEl.setAttribute('aria-hidden', 'true');
317
+ indicatorEl.innerHTML = SVG_ICONS.arrowDown;
108
318
  this.dialogueBoxEl.appendChild(this.speakerTagEl);
109
319
  this.dialogueBoxEl.appendChild(this.dialogueTextEl);
110
320
  this.dialogueBoxEl.appendChild(indicatorEl);
111
321
  // Quick Menu
112
322
  const quickMenuEl = document.createElement('nav');
113
323
  quickMenuEl.className = 'kawa-quick-menu';
324
+ quickMenuEl.setAttribute('role', 'toolbar');
325
+ quickMenuEl.setAttribute('aria-label', 'Game Menu');
114
326
  this.backBtnEl = document.createElement('button');
115
327
  this.backBtnEl.className = 'kawa-btn';
116
- this.backBtnEl.textContent = 'Back';
328
+ this.backBtnEl.innerHTML = `${SVG_ICONS.back} <span>Back</span>`;
329
+ this.backBtnEl.setAttribute('aria-label', 'Rollback to previous dialogue');
117
330
  const histBtn = document.createElement('button');
118
331
  histBtn.className = 'kawa-btn';
119
- histBtn.textContent = 'History';
332
+ histBtn.innerHTML = `${SVG_ICONS.history} <span>History</span>`;
333
+ histBtn.setAttribute('aria-label', 'Open dialogue history log');
120
334
  histBtn.addEventListener('click', (e) => {
121
335
  e.stopPropagation();
122
336
  this.showHistoryModal();
123
337
  });
338
+ this.autoBtnEl = document.createElement('button');
339
+ this.autoBtnEl.className = 'kawa-btn';
340
+ this.autoBtnEl.innerHTML = `${SVG_ICONS.auto} <span>Auto</span>`;
341
+ this.autoBtnEl.setAttribute('aria-label', 'Toggle Auto Forward Mode');
342
+ this.autoBtnEl.addEventListener('click', (e) => {
343
+ e.stopPropagation();
344
+ this.toggleAutoMode();
345
+ });
346
+ this.skipBtnEl = document.createElement('button');
347
+ this.skipBtnEl.className = 'kawa-btn';
348
+ this.skipBtnEl.innerHTML = `${SVG_ICONS.skip} <span>Skip</span>`;
349
+ this.skipBtnEl.setAttribute('aria-label', 'Toggle Fast Forward Skip Mode');
350
+ this.skipBtnEl.addEventListener('click', (e) => {
351
+ e.stopPropagation();
352
+ this.toggleSkipMode();
353
+ });
124
354
  const saveBtn = document.createElement('button');
125
355
  saveBtn.className = 'kawa-btn';
126
- saveBtn.textContent = 'Save';
356
+ saveBtn.innerHTML = `${SVG_ICONS.save} <span>Save</span>`;
357
+ saveBtn.setAttribute('aria-label', 'Open save game menu');
127
358
  saveBtn.addEventListener('click', (e) => {
128
359
  e.stopPropagation();
129
360
  this.showSaveLoadModal('save');
130
361
  });
131
362
  const loadBtn = document.createElement('button');
132
363
  loadBtn.className = 'kawa-btn';
133
- loadBtn.textContent = 'Load';
364
+ loadBtn.innerHTML = `${SVG_ICONS.load} <span>Load</span>`;
365
+ loadBtn.setAttribute('aria-label', 'Open load game menu');
134
366
  loadBtn.addEventListener('click', (e) => {
135
367
  e.stopPropagation();
136
368
  this.showSaveLoadModal('load');
137
369
  });
370
+ const settingsBtn = document.createElement('button');
371
+ settingsBtn.className = 'kawa-btn';
372
+ settingsBtn.innerHTML = `${SVG_ICONS.settings} <span>Settings</span>`;
373
+ settingsBtn.setAttribute('aria-label', 'Open Settings Menu');
374
+ settingsBtn.addEventListener('click', (e) => {
375
+ e.stopPropagation();
376
+ this.showSettingsModal();
377
+ });
138
378
  quickMenuEl.appendChild(this.backBtnEl);
139
379
  quickMenuEl.appendChild(histBtn);
380
+ quickMenuEl.appendChild(this.autoBtnEl);
381
+ quickMenuEl.appendChild(this.skipBtnEl);
140
382
  quickMenuEl.appendChild(saveBtn);
141
383
  quickMenuEl.appendChild(loadBtn);
384
+ quickMenuEl.appendChild(settingsBtn);
142
385
  uiLayerEl.appendChild(this.choiceContainerEl);
143
386
  uiLayerEl.appendChild(this.dialogueBoxEl);
144
387
  uiLayerEl.appendChild(quickMenuEl);
145
388
  this.stageEl.appendChild(this.backgroundEl);
146
389
  this.stageEl.appendChild(this.charactersEl);
390
+ this.stageEl.appendChild(this.modeBadgeEl);
147
391
  this.stageEl.appendChild(uiLayerEl);
148
392
  this.rootEl.appendChild(this.stageEl);
149
393
  this.container.appendChild(this.rootEl);
@@ -151,15 +395,24 @@ export class DOMRenderer {
151
395
  bindEvents() {
152
396
  // Clicking on dialogue advances or finishes typewriter
153
397
  this.dialogueBoxEl.addEventListener('click', () => {
398
+ if (this.isAutoMode) {
399
+ this.toggleAutoMode(false);
400
+ }
401
+ if (this.isSkipMode) {
402
+ this.toggleSkipMode(false);
403
+ }
154
404
  this.handleUserAdvance();
155
405
  });
156
406
  this.backBtnEl.addEventListener('click', (e) => {
157
407
  e.stopPropagation();
408
+ if (this.isAutoMode)
409
+ this.toggleAutoMode(false);
410
+ if (this.isSkipMode)
411
+ this.toggleSkipMode(false);
158
412
  this.vm.rollback();
159
413
  });
160
414
  // Keyboard controls
161
- window.addEventListener('keydown', (e) => {
162
- // If modal is open, let Escape close it
415
+ this.boundKeyHandler = (e) => {
163
416
  const modal = this.rootEl.querySelector('.kawa-modal-overlay');
164
417
  if (modal) {
165
418
  if (e.key === 'Escape')
@@ -167,13 +420,31 @@ export class DOMRenderer {
167
420
  return;
168
421
  }
169
422
  if (e.code === 'Space' || e.code === 'Enter') {
423
+ if (document.activeElement && document.activeElement.tagName === 'BUTTON') {
424
+ return;
425
+ }
170
426
  e.preventDefault();
427
+ if (this.isAutoMode)
428
+ this.toggleAutoMode(false);
429
+ if (this.isSkipMode)
430
+ this.toggleSkipMode(false);
171
431
  this.handleUserAdvance();
172
432
  }
173
433
  else if (e.code === 'Backspace') {
174
434
  e.preventDefault();
435
+ if (this.isAutoMode)
436
+ this.toggleAutoMode(false);
437
+ if (this.isSkipMode)
438
+ this.toggleSkipMode(false);
175
439
  this.vm.rollback();
176
440
  }
441
+ else if (e.key === 'a' || e.key === 'A') {
442
+ this.toggleAutoMode();
443
+ }
444
+ else if (e.key === 'Tab' || e.key === 'Control') {
445
+ e.preventDefault();
446
+ this.toggleSkipMode();
447
+ }
177
448
  else if (e.key === 's' || e.key === 'S') {
178
449
  this.showSaveLoadModal('save');
179
450
  }
@@ -183,7 +454,13 @@ export class DOMRenderer {
183
454
  else if (e.key === 'h' || e.key === 'H') {
184
455
  this.showHistoryModal();
185
456
  }
186
- });
457
+ else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
458
+ this.showSettingsModal();
459
+ }
460
+ };
461
+ if (typeof window !== 'undefined') {
462
+ window.addEventListener('keydown', this.boundKeyHandler);
463
+ }
187
464
  }
188
465
  handleUserAdvance() {
189
466
  if (this.isTypewriting) {
@@ -196,84 +473,120 @@ export class DOMRenderer {
196
473
  render(state) {
197
474
  this.isChoicePending = false;
198
475
  this.backBtnEl.disabled = !this.vm.canRollback();
199
- // Render background
476
+ // 1. Dual-Layer Background Crossfading
200
477
  if (state.visual.background) {
478
+ this.backgroundEl.dataset.transition = state.visual.transition ?? 'none';
201
479
  const rawBg = state.visual.background;
202
480
  const cleanBg = rawBg.replace(/^bg[\s_]+/i, '').trim();
203
481
  const baseName = cleanBg.replace(/\.(svg|png|jpg|jpeg|webp)$/i, '');
204
482
  const primaryUrl = this.assetResolver(cleanBg, 'background');
205
- const img = new Image();
206
- img.onload = () => {
207
- this.backgroundEl.style.backgroundImage = `url("${primaryUrl}")`;
208
- this.backgroundEl.style.opacity = '1';
209
- };
210
- img.onerror = () => {
211
- const fallbackPng = this.assetResolver(`${baseName}.png`, 'background');
212
- const img2 = new Image();
213
- img2.onload = () => {
214
- this.backgroundEl.style.backgroundImage = `url("${fallbackPng}")`;
215
- this.backgroundEl.style.opacity = '1';
216
- };
217
- img2.onerror = () => {
218
- // Graceful gradient fallback to prevent any black screen
219
- this.backgroundEl.style.backgroundImage = 'radial-gradient(ellipse at center, #334155 0%, #0f172a 100%)';
220
- this.backgroundEl.style.opacity = '1';
483
+ if (primaryUrl !== this.currentBgUrl) {
484
+ this.currentBgUrl = primaryUrl;
485
+ const incomingLayer = this.activeBgLayer === 'A' ? this.bgLayerB : this.bgLayerA;
486
+ const outgoingLayer = this.activeBgLayer === 'A' ? this.bgLayerA : this.bgLayerB;
487
+ incomingLayer.style.backgroundImage = `url("${primaryUrl}")`;
488
+ incomingLayer.classList.add('active');
489
+ outgoingLayer.classList.remove('active');
490
+ this.activeBgLayer = this.activeBgLayer === 'A' ? 'B' : 'A';
491
+ // Verify and fallback if image fails to load
492
+ const testImg = new Image();
493
+ testImg.onerror = () => {
494
+ const fallbackPng = this.assetResolver(`${baseName}.png`, 'background');
495
+ const img2 = new Image();
496
+ img2.onload = () => {
497
+ incomingLayer.style.backgroundImage = `url("${fallbackPng}")`;
498
+ };
499
+ img2.onerror = () => {
500
+ incomingLayer.style.backgroundImage = 'radial-gradient(ellipse at center, #334155 0%, #0f172a 100%)';
501
+ };
502
+ img2.src = fallbackPng;
221
503
  };
222
- img2.src = fallbackPng;
223
- };
224
- img.src = primaryUrl;
504
+ testImg.src = primaryUrl;
505
+ }
225
506
  }
226
507
  else {
227
- this.backgroundEl.style.opacity = '0';
508
+ this.currentBgUrl = '';
509
+ this.bgLayerA.classList.remove('active');
510
+ this.bgLayerB.classList.remove('active');
228
511
  }
229
- // Render character sprites
230
- this.charactersEl.innerHTML = '';
231
- for (const [charId, charState] of Object.entries(state.visual.characters)) {
232
- const spriteDiv = document.createElement('div');
233
- const posClass = `kawa-pos-${charState.position ?? 'center'}`;
234
- spriteDiv.className = `kawa-sprite ${posClass}`;
235
- spriteDiv.dataset.character = charId;
236
- if (charState.expression) {
237
- spriteDiv.dataset.expression = charState.expression;
512
+ // 2. Character Sprites DOM Reconciliation
513
+ const currentChars = state.visual.characters;
514
+ const currentIds = new Set(Object.keys(currentChars));
515
+ // Remove sprites no longer active
516
+ for (const [id, record] of this.activeCharacters.entries()) {
517
+ if (!currentIds.has(id)) {
518
+ record.div.remove();
519
+ this.activeCharacters.delete(id);
238
520
  }
239
- const img = document.createElement('img');
521
+ }
522
+ // Add or update active sprites
523
+ for (const [charId, charState] of Object.entries(currentChars)) {
524
+ const posClass = `kawa-pos-${charState.position ?? 'center'}`;
240
525
  const charAssetKey = charState.expression ? `${charId}/${charState.expression}` : charId;
241
- img.src = this.assetResolver(charAssetKey, 'character');
242
- img.alt = `${charId} ${charState.expression ?? ''}`;
243
- let fallbackStep = 0;
244
- img.onerror = () => {
245
- fallbackStep++;
246
- if (fallbackStep === 1) {
247
- // Try .svg extension
248
- img.src = this.assetResolver(`${charAssetKey}.svg`, 'character');
526
+ const primarySpriteUrl = this.assetResolver(charAssetKey, 'character');
527
+ let existing = this.activeCharacters.get(charId);
528
+ if (!existing) {
529
+ const spriteDiv = document.createElement('div');
530
+ spriteDiv.className = `kawa-sprite ${posClass}`;
531
+ spriteDiv.dataset.character = charId;
532
+ if (charState.expression) {
533
+ spriteDiv.dataset.expression = charState.expression;
249
534
  }
250
- else if (fallbackStep === 2 && charState.expression) {
251
- // Try flat filename e.g. yumia_happy.png
252
- img.src = this.assetResolver(`${charId}_${charState.expression}`, 'character');
253
- }
254
- else if (fallbackStep === 3 && charState.expression) {
255
- // Try flat filename with svg e.g. yumia_happy.svg
256
- img.src = this.assetResolver(`${charId}_${charState.expression}.svg`, 'character');
535
+ const img = document.createElement('img');
536
+ img.src = primarySpriteUrl;
537
+ img.alt = `${charId} ${charState.expression ?? ''}`;
538
+ let fallbackStep = 0;
539
+ img.onerror = () => {
540
+ fallbackStep++;
541
+ if (fallbackStep === 1) {
542
+ img.src = this.assetResolver(`${charAssetKey}.svg`, 'character');
543
+ }
544
+ else if (fallbackStep === 2 && charState.expression) {
545
+ img.src = this.assetResolver(`${charId}_${charState.expression}`, 'character');
546
+ }
547
+ else if (fallbackStep === 3 && charState.expression) {
548
+ img.src = this.assetResolver(`${charId}_${charState.expression}.svg`, 'character');
549
+ }
550
+ else {
551
+ img.style.display = 'none';
552
+ spriteDiv.style.width = '220px';
553
+ spriteDiv.style.height = '420px';
554
+ spriteDiv.style.background = 'rgba(244, 63, 94, 0.25)';
555
+ spriteDiv.style.border = '2px dashed #f43f5e';
556
+ spriteDiv.style.borderRadius = '16px';
557
+ spriteDiv.style.display = 'flex';
558
+ spriteDiv.style.alignItems = 'center';
559
+ spriteDiv.style.justifyContent = 'center';
560
+ spriteDiv.style.color = '#ffffff';
561
+ spriteDiv.style.fontWeight = '600';
562
+ spriteDiv.style.fontSize = '1.1rem';
563
+ spriteDiv.textContent = `${charId}\n(${charState.expression ?? 'normal'})`;
564
+ }
565
+ };
566
+ spriteDiv.appendChild(img);
567
+ this.charactersEl.appendChild(spriteDiv);
568
+ existing = {
569
+ div: spriteDiv,
570
+ img,
571
+ expression: charState.expression,
572
+ position: charState.position
573
+ };
574
+ this.activeCharacters.set(charId, existing);
575
+ }
576
+ else {
577
+ existing.div.className = `kawa-sprite ${posClass}`;
578
+ if (charState.expression) {
579
+ existing.div.dataset.expression = charState.expression;
257
580
  }
258
581
  else {
259
- // Fallback placeholder sprite if image is missing
260
- img.style.display = 'none';
261
- spriteDiv.style.width = '220px';
262
- spriteDiv.style.height = '420px';
263
- spriteDiv.style.background = 'rgba(244, 63, 94, 0.25)';
264
- spriteDiv.style.border = '2px dashed #f43f5e';
265
- spriteDiv.style.borderRadius = '16px';
266
- spriteDiv.style.display = 'flex';
267
- spriteDiv.style.alignItems = 'center';
268
- spriteDiv.style.justifyContent = 'center';
269
- spriteDiv.style.color = '#ffffff';
270
- spriteDiv.style.fontWeight = '600';
271
- spriteDiv.style.fontSize = '1.1rem';
272
- spriteDiv.textContent = `${charId}\n(${charState.expression ?? 'normal'})`;
582
+ delete existing.div.dataset.expression;
583
+ }
584
+ if (existing.expression !== charState.expression) {
585
+ existing.expression = charState.expression;
586
+ existing.img.style.display = 'block';
587
+ existing.img.src = primarySpriteUrl;
273
588
  }
274
- };
275
- spriteDiv.appendChild(img);
276
- this.charactersEl.appendChild(spriteDiv);
589
+ }
277
590
  }
278
591
  // Render dialogue
279
592
  if (state.dialogue) {
@@ -295,6 +608,10 @@ export class DOMRenderer {
295
608
  }
296
609
  // Render choices
297
610
  if (state.choices && state.choices.length > 0) {
611
+ if (this.isAutoMode)
612
+ this.toggleAutoMode(false);
613
+ if (this.isSkipMode)
614
+ this.toggleSkipMode(false);
298
615
  this.choiceContainerEl.innerHTML = '';
299
616
  this.choiceContainerEl.style.display = 'flex';
300
617
  this.choiceContainerEl.style.pointerEvents = 'auto';
@@ -320,17 +637,21 @@ export class DOMRenderer {
320
637
  }
321
638
  // Render Ending Card if Story is Finished
322
639
  if (state.isFinished) {
640
+ if (this.isAutoMode)
641
+ this.toggleAutoMode(false);
642
+ if (this.isSkipMode)
643
+ this.toggleSkipMode(false);
323
644
  this.choiceContainerEl.style.display = 'none';
324
645
  const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
325
646
  if (!existingEnd) {
326
647
  const endCard = document.createElement('div');
327
648
  endCard.className = 'kawa-ending-card';
328
649
  endCard.innerHTML = `
329
- <div class="kawa-ending-title">🌸 The End</div>
650
+ <div class="kawa-ending-title">The End</div>
330
651
  <div class="kawa-ending-subtitle">Story complete! Thank you for playing.</div>
331
652
  <div style="display:flex;gap:12px;margin-top:16px;">
332
- <button class="kawa-btn kawa-btn-replay">🔄 Play Again</button>
333
- <button class="kawa-btn kawa-btn-load-end">📂 Load Slot</button>
653
+ <button class="kawa-btn kawa-btn-replay">${SVG_ICONS.replay} Play Again</button>
654
+ <button class="kawa-btn kawa-btn-load-end">${SVG_ICONS.load} Load Slot</button>
334
655
  </div>
335
656
  `;
336
657
  endCard.querySelector('.kawa-btn-replay')?.addEventListener('click', () => {
@@ -352,18 +673,24 @@ export class DOMRenderer {
352
673
  if (this.currentTypewriterInterval) {
353
674
  clearInterval(this.currentTypewriterInterval);
354
675
  }
676
+ if (this.autoTimer) {
677
+ clearTimeout(this.autoTimer);
678
+ this.autoTimer = null;
679
+ }
355
680
  this.fullCurrentText = text;
356
- if (this.typewriterSpeed <= 0) {
357
- this.dialogueTextEl.textContent = text;
681
+ if (this.typewriterSpeed <= 0 || this.isSkipMode) {
682
+ this.dialogueTextEl.innerHTML = formatRichText(text);
358
683
  this.isTypewriting = false;
684
+ if (this.isAutoMode)
685
+ this.scheduleAutoAdvance();
359
686
  return;
360
687
  }
361
688
  this.isTypewriting = true;
362
689
  let index = 0;
363
- this.dialogueTextEl.textContent = '';
690
+ this.dialogueTextEl.innerHTML = '';
364
691
  this.currentTypewriterInterval = window.setInterval(() => {
365
692
  index += 1;
366
- this.dialogueTextEl.textContent = this.fullCurrentText.slice(0, index);
693
+ this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText.slice(0, index));
367
694
  if (index >= this.fullCurrentText.length) {
368
695
  this.finishTypewriter();
369
696
  }
@@ -374,8 +701,111 @@ export class DOMRenderer {
374
701
  clearInterval(this.currentTypewriterInterval);
375
702
  this.currentTypewriterInterval = null;
376
703
  }
377
- this.dialogueTextEl.textContent = this.fullCurrentText;
704
+ this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText);
378
705
  this.isTypewriting = false;
706
+ if (this.isAutoMode) {
707
+ this.scheduleAutoAdvance();
708
+ }
709
+ }
710
+ showSettingsModal() {
711
+ const existing = this.rootEl.querySelector('.kawa-modal-overlay');
712
+ if (existing)
713
+ existing.remove();
714
+ const overlay = document.createElement('div');
715
+ overlay.className = 'kawa-modal-overlay';
716
+ const card = document.createElement('div');
717
+ card.className = 'kawa-modal-card';
718
+ card.setAttribute('role', 'dialog');
719
+ card.setAttribute('aria-modal', 'true');
720
+ card.setAttribute('aria-label', 'Settings');
721
+ const header = document.createElement('div');
722
+ header.className = 'kawa-modal-header';
723
+ const title = document.createElement('div');
724
+ title.className = 'kawa-modal-title';
725
+ title.innerHTML = `${SVG_ICONS.settings} <span>Settings</span>`;
726
+ const closeBtn = document.createElement('button');
727
+ closeBtn.className = 'kawa-btn';
728
+ closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
729
+ closeBtn.setAttribute('aria-label', 'Close settings modal');
730
+ closeBtn.addEventListener('click', () => overlay.remove());
731
+ header.appendChild(title);
732
+ header.appendChild(closeBtn);
733
+ const body = document.createElement('div');
734
+ body.className = 'kawa-modal-body kawa-settings-group';
735
+ // 1. Text Speed Slider
736
+ const speedRow = document.createElement('div');
737
+ speedRow.className = 'kawa-setting-row';
738
+ const speedVal = this.typewriterSpeed;
739
+ speedRow.innerHTML = `
740
+ <div class="kawa-setting-header">
741
+ <span>Text Speed</span>
742
+ <span class="kawa-setting-value" id="kawa-speed-val">${speedVal === 0 ? 'Instant' : `${speedVal}ms`}</span>
743
+ </div>
744
+ <input type="range" class="kawa-slider" id="kawa-speed-slider" min="0" max="60" step="5" value="${speedVal}">
745
+ `;
746
+ const speedInput = speedRow.querySelector('#kawa-speed-slider');
747
+ const speedValEl = speedRow.querySelector('#kawa-speed-val');
748
+ speedInput.addEventListener('input', () => {
749
+ this.typewriterSpeed = Number(speedInput.value);
750
+ speedValEl.textContent = this.typewriterSpeed === 0 ? 'Instant' : `${this.typewriterSpeed}ms`;
751
+ this.saveSettings();
752
+ });
753
+ // 2. Auto Forward Delay Slider
754
+ const autoRow = document.createElement('div');
755
+ autoRow.className = 'kawa-setting-row';
756
+ const autoVal = (this.autoDelayMs / 1000).toFixed(1);
757
+ autoRow.innerHTML = `
758
+ <div class="kawa-setting-header">
759
+ <span>Auto Forward Delay</span>
760
+ <span class="kawa-setting-value" id="kawa-auto-val">${autoVal}s</span>
761
+ </div>
762
+ <input type="range" class="kawa-slider" id="kawa-auto-slider" min="500" max="5000" step="250" value="${this.autoDelayMs}">
763
+ `;
764
+ const autoInput = autoRow.querySelector('#kawa-auto-slider');
765
+ const autoValEl = autoRow.querySelector('#kawa-auto-val');
766
+ autoInput.addEventListener('input', () => {
767
+ this.autoDelayMs = Number(autoInput.value);
768
+ autoValEl.textContent = `${(this.autoDelayMs / 1000).toFixed(1)}s`;
769
+ this.saveSettings();
770
+ });
771
+ // 3. Audio Volume Sliders
772
+ const musicRow = document.createElement('div');
773
+ musicRow.className = 'kawa-setting-row';
774
+ musicRow.innerHTML = `
775
+ <div class="kawa-setting-header">
776
+ <span>${SVG_ICONS.volumeOn} Music Volume</span>
777
+ <span class="kawa-setting-value" id="kawa-music-val">80%</span>
778
+ </div>
779
+ <input type="range" class="kawa-slider" id="kawa-music-slider" min="0" max="100" step="5" value="80">
780
+ `;
781
+ const sfxRow = document.createElement('div');
782
+ sfxRow.className = 'kawa-setting-row';
783
+ sfxRow.innerHTML = `
784
+ <div class="kawa-setting-header">
785
+ <span>${SVG_ICONS.volumeOn} SFX Volume</span>
786
+ <span class="kawa-setting-value" id="kawa-sfx-val">100%</span>
787
+ </div>
788
+ <input type="range" class="kawa-slider" id="kawa-sfx-slider" min="0" max="100" step="5" value="100">
789
+ `;
790
+ body.appendChild(speedRow);
791
+ body.appendChild(autoRow);
792
+ body.appendChild(musicRow);
793
+ body.appendChild(sfxRow);
794
+ card.appendChild(header);
795
+ card.appendChild(body);
796
+ overlay.appendChild(card);
797
+ this.rootEl.appendChild(overlay);
798
+ }
799
+ saveSettings() {
800
+ if (typeof localStorage !== 'undefined') {
801
+ try {
802
+ localStorage.setItem('kawaijs_settings', JSON.stringify({
803
+ typewriterSpeed: this.typewriterSpeed,
804
+ autoDelayMs: this.autoDelayMs
805
+ }));
806
+ }
807
+ catch { }
808
+ }
379
809
  }
380
810
  async showSaveLoadModal(mode) {
381
811
  const existing = this.rootEl.querySelector('.kawa-modal-overlay');
@@ -385,14 +815,18 @@ export class DOMRenderer {
385
815
  overlay.className = 'kawa-modal-overlay';
386
816
  const card = document.createElement('div');
387
817
  card.className = 'kawa-modal-card';
818
+ card.setAttribute('role', 'dialog');
819
+ card.setAttribute('aria-modal', 'true');
820
+ card.setAttribute('aria-label', mode === 'save' ? 'Save Game' : 'Load Game');
388
821
  const header = document.createElement('div');
389
822
  header.className = 'kawa-modal-header';
390
823
  const title = document.createElement('div');
391
824
  title.className = 'kawa-modal-title';
392
- title.textContent = mode === 'save' ? '💾 Save Game' : '📂 Load Game';
825
+ title.innerHTML = `${mode === 'save' ? SVG_ICONS.save : SVG_ICONS.load} <span>${mode === 'save' ? 'Save Game' : 'Load Game'}</span>`;
393
826
  const closeBtn = document.createElement('button');
394
827
  closeBtn.className = 'kawa-btn';
395
- closeBtn.textContent = '✕ Close';
828
+ closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
829
+ closeBtn.setAttribute('aria-label', 'Close modal');
396
830
  closeBtn.addEventListener('click', () => overlay.remove());
397
831
  header.appendChild(title);
398
832
  header.appendChild(closeBtn);
@@ -434,7 +868,8 @@ export class DOMRenderer {
434
868
  if (mode === 'save') {
435
869
  const saveActionBtn = document.createElement('button');
436
870
  saveActionBtn.className = 'kawa-slot-btn kawa-slot-btn-save';
437
- saveActionBtn.textContent = 'Save Here';
871
+ saveActionBtn.innerHTML = `${SVG_ICONS.save} Save Here`;
872
+ saveActionBtn.setAttribute('aria-label', `Save to slot ${slotNum}`);
438
873
  saveActionBtn.addEventListener('click', async () => {
439
874
  await this.vm.save(slotNum);
440
875
  overlay.remove();
@@ -446,7 +881,8 @@ export class DOMRenderer {
446
881
  if (slot) {
447
882
  const loadActionBtn = document.createElement('button');
448
883
  loadActionBtn.className = 'kawa-slot-btn kawa-slot-btn-load';
449
- loadActionBtn.textContent = 'Load';
884
+ loadActionBtn.innerHTML = `${SVG_ICONS.load} Load`;
885
+ loadActionBtn.setAttribute('aria-label', `Load slot ${slotNum}`);
450
886
  loadActionBtn.addEventListener('click', async () => {
451
887
  const ok = await this.vm.load(slotNum);
452
888
  if (ok)
@@ -458,8 +894,9 @@ export class DOMRenderer {
458
894
  if (slot) {
459
895
  const delBtn = document.createElement('button');
460
896
  delBtn.className = 'kawa-slot-btn kawa-slot-btn-del';
461
- delBtn.textContent = '🗑';
897
+ delBtn.innerHTML = SVG_ICONS.trash;
462
898
  delBtn.title = 'Delete Save';
899
+ delBtn.setAttribute('aria-label', `Delete slot ${slotNum}`);
463
900
  delBtn.addEventListener('click', async () => {
464
901
  await saveManager.deleteSlot(slotNum);
465
902
  overlay.remove();
@@ -484,14 +921,18 @@ export class DOMRenderer {
484
921
  overlay.className = 'kawa-modal-overlay';
485
922
  const card = document.createElement('div');
486
923
  card.className = 'kawa-modal-card';
924
+ card.setAttribute('role', 'dialog');
925
+ card.setAttribute('aria-modal', 'true');
926
+ card.setAttribute('aria-label', 'Dialogue History');
487
927
  const header = document.createElement('div');
488
928
  header.className = 'kawa-modal-header';
489
929
  const title = document.createElement('div');
490
930
  title.className = 'kawa-modal-title';
491
- title.textContent = '📜 Dialogue History';
931
+ title.innerHTML = `${SVG_ICONS.history} <span>Dialogue History</span>`;
492
932
  const closeBtn = document.createElement('button');
493
933
  closeBtn.className = 'kawa-btn';
494
- closeBtn.textContent = '✕ Close';
934
+ closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
935
+ closeBtn.setAttribute('aria-label', 'Close history modal');
495
936
  closeBtn.addEventListener('click', () => overlay.remove());
496
937
  header.appendChild(title);
497
938
  header.appendChild(closeBtn);
@@ -515,7 +956,7 @@ export class DOMRenderer {
515
956
  item.appendChild(spk);
516
957
  }
517
958
  const txt = document.createElement('div');
518
- txt.textContent = entry.text;
959
+ txt.innerHTML = formatRichText(entry.text);
519
960
  item.appendChild(txt);
520
961
  body.appendChild(item);
521
962
  }