@bunnyland/ui-web 0.2.0

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.
Files changed (57) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +211 -0
  3. package/assets/bunnyland-api.js +345 -0
  4. package/assets/bunnyland-play.js +1223 -0
  5. package/assets/bunnyland-ui.css +1634 -0
  6. package/assets/bunnyland-ui.js +795 -0
  7. package/dist/admin-widgets.d.ts +31 -0
  8. package/dist/admin-widgets.d.ts.map +1 -0
  9. package/dist/admin-widgets.js +100 -0
  10. package/dist/admin-widgets.js.map +1 -0
  11. package/dist/api.d.ts +37 -0
  12. package/dist/api.d.ts.map +1 -0
  13. package/dist/api.js +125 -0
  14. package/dist/api.js.map +1 -0
  15. package/dist/index.d.ts +7 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +7 -0
  18. package/dist/play.d.ts +265 -0
  19. package/dist/play.d.ts.map +1 -0
  20. package/dist/play.js +806 -0
  21. package/dist/play.js.map +1 -0
  22. package/dist/player-widgets.d.ts +11 -0
  23. package/dist/player-widgets.d.ts.map +1 -0
  24. package/dist/player-widgets.js +21 -0
  25. package/dist/player-widgets.js.map +1 -0
  26. package/dist/preact/auth.d.ts +37 -0
  27. package/dist/preact/auth.d.ts.map +1 -0
  28. package/dist/preact/components.d.ts +58 -0
  29. package/dist/preact/components.d.ts.map +1 -0
  30. package/dist/preact/theme.d.ts +16 -0
  31. package/dist/preact/theme.d.ts.map +1 -0
  32. package/dist/preact.d.ts +4 -0
  33. package/dist/preact.d.ts.map +1 -0
  34. package/dist/preact.js +488 -0
  35. package/dist/preact.js.map +1 -0
  36. package/dist/theme.d.ts +31 -0
  37. package/dist/theme.d.ts.map +1 -0
  38. package/dist/theme.js +165 -0
  39. package/dist/theme.js.map +1 -0
  40. package/dist/widgets.d.ts +7 -0
  41. package/dist/widgets.d.ts.map +1 -0
  42. package/dist/widgets.js +31 -0
  43. package/dist/widgets.js.map +1 -0
  44. package/docs/admin/custom-web-themes.md +112 -0
  45. package/docs/developer/design-language.md +116 -0
  46. package/package.json +101 -0
  47. package/src/admin-widgets.ts +189 -0
  48. package/src/api.ts +193 -0
  49. package/src/index.ts +6 -0
  50. package/src/play.ts +1151 -0
  51. package/src/player-widgets.ts +29 -0
  52. package/src/preact/auth.tsx +338 -0
  53. package/src/preact/components.tsx +249 -0
  54. package/src/preact/theme.tsx +72 -0
  55. package/src/preact.ts +3 -0
  56. package/src/theme.ts +225 -0
  57. package/src/widgets.ts +41 -0
@@ -0,0 +1,795 @@
1
+ (function () {
2
+ 'use strict';
3
+
4
+ const THEME_KEY = 'bunnyland.theme';
5
+ const THEME_CLASS_PREFIX = 'bl-theme-';
6
+ const COLOR_SCHEME_KEY = 'bunnyland.color-scheme';
7
+ const COLOR_SCHEME_CLASS_PREFIX = 'bl-color-scheme-';
8
+ const DEFAULT_THEME = 'purple-blue';
9
+ const THEME_ALIASES = {
10
+ anime: 'candy',
11
+ 'anime-dark': 'candy-dark',
12
+ 'anime-light': 'candy-light',
13
+ dark: 'purple-blue-dark',
14
+ light: 'purple-blue-light',
15
+ };
16
+ const DEFAULT_THEME_OPTIONS = [
17
+ { value: 'purple-blue', label: 'Purple / Blue' },
18
+ { value: 'candy', label: 'Candy Pink / Cyan' },
19
+ { value: 'earth', label: 'Earth Green / Gold' },
20
+ { value: 'ocean', label: 'Ocean Teal / Coral' },
21
+ { value: 'sunset', label: 'Sunset Orange / Plum' },
22
+ { value: 'high-contrast', label: 'High Contrast' },
23
+ ];
24
+ const COLOR_SCHEME_OPTIONS = [
25
+ { value: 'auto', label: 'Auto (System)' },
26
+ { value: 'dark', label: 'Dark' },
27
+ { value: 'light', label: 'Light' },
28
+ ];
29
+ const THEME_OPTIONS = DEFAULT_THEME_OPTIONS.map(option => ({ ...option }));
30
+ const THEME_VALUE_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
31
+ const boundThemeSelects = new Set();
32
+ const boundColorSchemeSelects = new Set();
33
+ const CLIENT_MENU_SEEN_KEY = 'bunnyland.clientMenu.seen';
34
+ let clientMenuBaseUrl = '';
35
+ // Admin tools order: World Generator, World Graph, editor tools alphabetically, then miscellaneous tools.
36
+ const CLIENT_MENU_ITEMS = [
37
+ {
38
+ href: 'index.html',
39
+ title: 'Welcome',
40
+ label: 'Start here',
41
+ description: 'Project overview, docs, admin notes, and client chooser.',
42
+ supportsServer: true,
43
+ },
44
+ {
45
+ href: 'https://bunnyland.dev/',
46
+ title: 'Bunnyland.dev',
47
+ label: 'Website',
48
+ description: 'Project homepage, feature overview, guides, and public docs.',
49
+ supportsServer: false,
50
+ },
51
+ {
52
+ href: 'toon-client.html',
53
+ title: 'Toon Client',
54
+ label: 'Player room view',
55
+ description: 'Claim a character and play from the room-focused visual client.',
56
+ supportsFocus: true,
57
+ supportsServer: true,
58
+ },
59
+ {
60
+ href: 'web-tui.html',
61
+ title: 'Web TUI',
62
+ label: 'Player action menu',
63
+ description: 'Claim a character and play from the terminal TUI-style browser client.',
64
+ supportsFocus: true,
65
+ supportsServer: true,
66
+ },
67
+ {
68
+ href: 'web-repl.html',
69
+ title: 'Web REPL',
70
+ label: 'Text-based play',
71
+ description: 'Claim a character and play with typed commands in the browser.',
72
+ supportsFocus: true,
73
+ supportsServer: true,
74
+ },
75
+ {
76
+ href: 'character.html',
77
+ title: 'Character Profile',
78
+ label: 'Profile and chat',
79
+ description: 'Open a character profile, inspect live state, and chat in character.',
80
+ supportsFocus: true,
81
+ supportsServer: true,
82
+ },
83
+ {
84
+ href: 'world-generator.html',
85
+ title: 'World Generator',
86
+ label: 'Admin generator',
87
+ description: 'Generate or replace a live world using enabled server generators.',
88
+ supportsServer: true,
89
+ admin: true,
90
+ },
91
+ {
92
+ href: 'inspector.html',
93
+ title: 'World Graph',
94
+ label: 'Graph editor',
95
+ description: 'Browse and extend the ECS world graph from a snapshot or live server.',
96
+ supportsServer: true,
97
+ admin: true,
98
+ },
99
+ {
100
+ href: 'behavior-editor.html',
101
+ title: 'Behavior Editor',
102
+ label: 'Behavior trees',
103
+ description: 'Author behavior-tree JSON for behavioral controllers and register it live.',
104
+ supportsServer: true,
105
+ admin: true,
106
+ },
107
+ {
108
+ href: 'character-memory.html',
109
+ title: 'Memory Editor',
110
+ label: 'Memory editor',
111
+ description: 'Inspect and edit character memory collections on a live server.',
112
+ supportsServer: true,
113
+ admin: true,
114
+ },
115
+ {
116
+ href: 'script-editor.html',
117
+ title: 'Script Editor',
118
+ label: 'Automation scripts',
119
+ description: 'Create and validate script JSON blocks against a snapshot.',
120
+ supportsServer: false,
121
+ admin: true,
122
+ },
123
+ {
124
+ href: 'world-editor.html',
125
+ title: 'World Editor',
126
+ label: 'Admin editor',
127
+ description: 'Edit entities, components, relationships, fragments, and live snapshots.',
128
+ supportsServer: true,
129
+ admin: true,
130
+ },
131
+ {
132
+ href: 'event-stream.html',
133
+ title: 'Event Stream',
134
+ label: 'Event viewer',
135
+ description: 'Watch the live world event feed with expandable records and entity references.',
136
+ supportsServer: true,
137
+ admin: true,
138
+ },
139
+ {
140
+ href: 'trace-analyzer.html',
141
+ title: 'Trace Analyzer',
142
+ label: 'Trace inspection',
143
+ description: 'Inspect live Tempo traces or load JSON and JSONL trace artifacts.',
144
+ supportsServer: true,
145
+ admin: true,
146
+ },
147
+ ];
148
+ const FOCUS_PAGE_NAMES = new Set([
149
+ 'toon-client.html', 'web-tui.html', 'web-repl.html', 'character.html',
150
+ ]);
151
+
152
+ let deployConfigPromise = null;
153
+
154
+ // Fetch the deployment's config.json once and reuse it. Every client already reads this
155
+ // file for serverUrl/autoConnect; the shared menu reads it too so a configured Discord
156
+ // invite and custom themes can appear on every page without each client wiring them up.
157
+ function loadConfig() {
158
+ if (!deployConfigPromise) {
159
+ deployConfigPromise = fetch('config.json', { cache: 'no-store' })
160
+ .then((res) => (res.ok ? res.json() : {}))
161
+ .then((config) => {
162
+ registerThemeOptions(config?.themes);
163
+ initTheme(config?.theme || config?.defaultTheme);
164
+ return config;
165
+ })
166
+ .catch(() => ({}));
167
+ }
168
+ return deployConfigPromise;
169
+ }
170
+
171
+ function escapeHtml(value) {
172
+ return String(value)
173
+ .replace(/&/g, '&')
174
+ .replace(/</g, '&lt;')
175
+ .replace(/>/g, '&gt;')
176
+ .replace(/"/g, '&quot;');
177
+ }
178
+
179
+ function cloneJson(value) {
180
+ return JSON.parse(JSON.stringify(value));
181
+ }
182
+
183
+ function normalizeTags(value) {
184
+ if (Array.isArray(value)) return value.map(tag => String(tag)).filter(Boolean);
185
+ if (typeof value === 'string') return value.split(',').map(tag => tag.trim()).filter(Boolean);
186
+ return [];
187
+ }
188
+
189
+ function tagEditorHtml(tags, options = {}) {
190
+ const values = normalizeTags(tags);
191
+ const hiddenClass = options.hiddenClass || 'component-tags-value';
192
+ const hiddenAttributes = options.hiddenAttributes || '';
193
+ const disabled = options.disabled ? ' disabled' : '';
194
+ const addLabel = options.addLabel || 'Add Tag';
195
+ const placeholder = options.placeholder || 'add tag...';
196
+ return `
197
+ <div class="tag-editor">
198
+ <input class="${escapeHtml(hiddenClass)}" ${hiddenAttributes} type="hidden" value="${escapeHtml(JSON.stringify(values))}">
199
+ <div class="tag-list">
200
+ ${values.map(tag => `
201
+ <span class="tag-pill">
202
+ <span>${escapeHtml(tag)}</span>
203
+ <button type="button" data-remove-tag="${escapeHtml(tag)}" aria-label="Remove tag ${escapeHtml(tag)}"${disabled}>x</button>
204
+ </span>
205
+ `).join('') || '<span class="tiny">No tags.</span>'}
206
+ </div>
207
+ <div class="tag-entry">
208
+ <input class="tag-input" type="text" placeholder="${escapeHtml(placeholder)}" spellcheck="false"${disabled}>
209
+ <button type="button" data-add-tag${disabled}>${escapeHtml(addLabel)}</button>
210
+ </div>
211
+ </div>
212
+ `;
213
+ }
214
+
215
+ function readTagEditorTags(editor, options = {}) {
216
+ const hidden = editor.querySelector(options.hiddenSelector || '.component-tags-value');
217
+ try {
218
+ const parsed = JSON.parse(hidden?.value || '[]');
219
+ return normalizeTags(parsed);
220
+ } catch (_err) {
221
+ return [];
222
+ }
223
+ }
224
+
225
+ function renderTagEditorTags(editor, tags) {
226
+ const list = editor.querySelector('.tag-list');
227
+ if (!list) return;
228
+ const values = normalizeTags(tags);
229
+ list.innerHTML = values.length
230
+ ? values.map(tag => `
231
+ <span class="tag-pill">
232
+ <span>${escapeHtml(tag)}</span>
233
+ <button type="button" data-remove-tag="${escapeHtml(tag)}" aria-label="Remove tag ${escapeHtml(tag)}">x</button>
234
+ </span>
235
+ `).join('')
236
+ : '<span class="tiny">No tags.</span>';
237
+ }
238
+
239
+ function bindTagEditor(editor, options = {}) {
240
+ const input = editor.querySelector('.tag-input');
241
+ const add = editor.querySelector('[data-add-tag]');
242
+ const hidden = editor.querySelector(options.hiddenSelector || '.component-tags-value');
243
+ const onChange = typeof options.onChange === 'function' ? options.onChange : () => {};
244
+ const update = (tags) => {
245
+ const values = normalizeTags(tags);
246
+ if (hidden) hidden.value = JSON.stringify(values);
247
+ renderTagEditorTags(editor, values);
248
+ onChange(values);
249
+ };
250
+ const addTag = () => {
251
+ const tag = input?.value.trim();
252
+ if (!tag) return;
253
+ const tags = readTagEditorTags(editor, options);
254
+ if (!tags.includes(tag)) tags.push(tag);
255
+ input.value = '';
256
+ update(tags);
257
+ input.focus();
258
+ };
259
+ add?.addEventListener('click', addTag);
260
+ input?.addEventListener('keydown', (event) => {
261
+ if (event.key !== 'Enter') return;
262
+ event.preventDefault();
263
+ addTag();
264
+ });
265
+ editor.addEventListener('click', (event) => {
266
+ const button = event.target.closest('[data-remove-tag]');
267
+ if (!button) return;
268
+ update(readTagEditorTags(editor, options).filter(tag => tag !== button.dataset.removeTag));
269
+ });
270
+ return { readTags: () => readTagEditorTags(editor, options), update };
271
+ }
272
+
273
+ function isKnownTheme(name) {
274
+ return THEME_OPTIONS.some(option => option.value === name);
275
+ }
276
+
277
+ function parseThemeSelection(name) {
278
+ const value = String(name || DEFAULT_THEME).trim();
279
+ const raw = THEME_ALIASES[value] || value;
280
+ if (isKnownTheme(raw)) return { theme: raw, scheme: null };
281
+ const match = raw.match(/^(.*)-(dark|light)$/);
282
+ if (match && isKnownTheme(match[1])) return { theme: match[1], scheme: match[2] };
283
+ return { theme: raw, scheme: null };
284
+ }
285
+
286
+ function normalizeThemeValue(name) {
287
+ return parseThemeSelection(name).theme;
288
+ }
289
+
290
+ function sanitizeThemeOption(option) {
291
+ const value = String(option?.value || '').trim();
292
+ if (!THEME_VALUE_PATTERN.test(value)) return null;
293
+ const label = String(option?.label || value).trim() || value;
294
+ return { value, label };
295
+ }
296
+
297
+ function renderThemeSelect(select) {
298
+ const theme = currentTheme();
299
+ select.innerHTML = themeOptions().map(option => `
300
+ <option value="${escapeHtml(option.value)}">${escapeHtml(option.label)}</option>
301
+ `).join('');
302
+ select.value = theme;
303
+ }
304
+
305
+ function refreshThemeSelects() {
306
+ for (const select of boundThemeSelects) renderThemeSelect(select);
307
+ }
308
+
309
+ function normalizeColorScheme(name) {
310
+ return name === 'dark' || name === 'light' ? name : 'auto';
311
+ }
312
+
313
+ function colorSchemeOptions() {
314
+ return COLOR_SCHEME_OPTIONS.map(option => ({ ...option }));
315
+ }
316
+
317
+ function currentColorScheme() {
318
+ return normalizeColorScheme(document.documentElement.dataset.colorScheme);
319
+ }
320
+
321
+ function renderColorSchemeSelect(select) {
322
+ select.innerHTML = colorSchemeOptions().map(option => `
323
+ <option value="${option.value}">${option.label}</option>
324
+ `).join('');
325
+ select.value = currentColorScheme();
326
+ }
327
+
328
+ function refreshColorSchemeSelects() {
329
+ for (const select of boundColorSchemeSelects) renderColorSchemeSelect(select);
330
+ }
331
+
332
+ function themeFromSearch(search = location.search || '') {
333
+ const requested = new URLSearchParams(search).get('theme');
334
+ if (!requested) return null;
335
+ const theme = normalizeThemeValue(requested);
336
+ return isKnownTheme(theme) ? theme : null;
337
+ }
338
+
339
+ function normalizeTheme(name) {
340
+ const theme = normalizeThemeValue(name);
341
+ return isKnownTheme(theme) ? theme : DEFAULT_THEME;
342
+ }
343
+
344
+ function currentTheme() {
345
+ return normalizeTheme(document.documentElement.dataset.theme);
346
+ }
347
+
348
+ function applyTheme(theme, persist = true) {
349
+ const root = document.documentElement;
350
+ for (const className of [...root.classList]) {
351
+ if (className.startsWith(THEME_CLASS_PREFIX)) root.classList.remove(className);
352
+ }
353
+ root.classList.add(`${THEME_CLASS_PREFIX}${theme}`);
354
+ root.dataset.theme = theme;
355
+ if (persist) storageSet(THEME_KEY, theme);
356
+ refreshThemeSelects();
357
+ return theme;
358
+ }
359
+
360
+ function applyColorScheme(scheme, persist = true) {
361
+ const root = document.documentElement;
362
+ for (const className of [...root.classList]) {
363
+ if (className.startsWith(COLOR_SCHEME_CLASS_PREFIX)) root.classList.remove(className);
364
+ }
365
+ if (scheme !== 'auto') root.classList.add(`${COLOR_SCHEME_CLASS_PREFIX}${scheme}`);
366
+ root.dataset.colorScheme = scheme;
367
+ if (persist) storageSet(COLOR_SCHEME_KEY, scheme);
368
+ refreshColorSchemeSelects();
369
+ return scheme;
370
+ }
371
+
372
+ function setColorScheme(name) {
373
+ return applyColorScheme(normalizeColorScheme(name), true);
374
+ }
375
+
376
+ function setTheme(name) {
377
+ const selection = parseThemeSelection(name);
378
+ if (selection.scheme) applyColorScheme(selection.scheme, true);
379
+ const theme = normalizeTheme(selection.theme);
380
+ return applyTheme(theme, true);
381
+ }
382
+
383
+ function initTheme(defaultTheme = DEFAULT_THEME, search = location.search || '') {
384
+ const linkedValue = new URLSearchParams(search).get('theme');
385
+ const linkedTheme = themeFromSearch(search);
386
+ const stored = storageGet(THEME_KEY);
387
+ const selection = parseThemeSelection((linkedTheme && linkedValue) || stored || defaultTheme || DEFAULT_THEME);
388
+ const storedScheme = storageGet(COLOR_SCHEME_KEY);
389
+ applyColorScheme(selection.scheme || normalizeColorScheme(storedScheme), Boolean(selection.scheme || storedScheme));
390
+ const theme = normalizeTheme(selection.theme);
391
+ return applyTheme(theme, Boolean(linkedTheme || (stored && isKnownTheme(selection.theme))));
392
+ }
393
+
394
+ function themeOptions() {
395
+ return THEME_OPTIONS.map(option => ({ ...option }));
396
+ }
397
+
398
+ function registerThemeOption(option) {
399
+ const theme = sanitizeThemeOption(option);
400
+ if (!theme) return null;
401
+ const index = THEME_OPTIONS.findIndex(existing => existing.value === theme.value);
402
+ if (index === -1) THEME_OPTIONS.push(theme);
403
+ else THEME_OPTIONS[index] = theme;
404
+ refreshThemeSelects();
405
+ return { ...theme };
406
+ }
407
+
408
+ function registerThemeOptions(options) {
409
+ if (!Array.isArray(options)) return [];
410
+ return options.map(option => registerThemeOption(option)).filter(Boolean);
411
+ }
412
+
413
+ function bindThemeSelect(select) {
414
+ if (!select) return null;
415
+ boundThemeSelects.add(select);
416
+ renderThemeSelect(select);
417
+ select.value = currentTheme();
418
+ select.addEventListener('change', () => setTheme(select.value));
419
+ return { setValue: (value) => { select.value = normalizeTheme(value); setTheme(select.value); } };
420
+ }
421
+
422
+ function bindColorSchemeSelect(select) {
423
+ if (!select) return null;
424
+ boundColorSchemeSelects.add(select);
425
+ renderColorSchemeSelect(select);
426
+ select.addEventListener('change', () => setColorScheme(select.value));
427
+ return { setValue: (value) => { select.value = normalizeColorScheme(value); setColorScheme(select.value); } };
428
+ }
429
+
430
+ function storageGet(key) {
431
+ try {
432
+ return localStorage.getItem(key);
433
+ } catch (_err) {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ function storageSet(key, value) {
439
+ try {
440
+ localStorage.setItem(key, value);
441
+ } catch (_err) {
442
+ // Storage is optional; the menu remains available from the toolbar.
443
+ }
444
+ }
445
+
446
+ function currentPageName() {
447
+ const path = location.pathname.split('/').pop();
448
+ return path || 'index.html';
449
+ }
450
+
451
+ function currentServerValue() {
452
+ const queryServer = new URLSearchParams(location.search).get('server') || '';
453
+ const input = document.getElementById('api-url');
454
+ if (input && queryServer && input.value.trim() === input.defaultValue.trim()) return queryServer;
455
+ if (input && input.value.trim()) return input.value.trim();
456
+ return queryServer;
457
+ }
458
+
459
+ function clientHref(item) {
460
+ const url = new URL(item.href, clientMenuBaseUrl || location.href);
461
+ url.hash = item.supportsFocus && FOCUS_PAGE_NAMES.has(currentPageName()) ? location.hash : '';
462
+ if (item.supportsServer) {
463
+ const server = currentServerValue();
464
+ if (server) url.searchParams.set('server', server);
465
+ }
466
+ if (url.origin !== location.origin) return url.toString();
467
+ if (clientMenuBaseUrl) return `${url.pathname}${url.search}${url.hash}`;
468
+ return `${url.pathname.split('/').pop()}${url.search}${url.hash}`;
469
+ }
470
+
471
+ function clientTargetAttrs(item) {
472
+ const url = new URL(item.href, location.href);
473
+ return url.origin === location.origin ? '' : ' target="_blank" rel="noopener"';
474
+ }
475
+
476
+ function ensureClientMenu() {
477
+ let dialog = document.getElementById('client-menu-dialog');
478
+ if (dialog) return dialog;
479
+
480
+ dialog = document.createElement('div');
481
+ dialog.id = 'client-menu-dialog';
482
+ dialog.className = 'client-menu-backdrop hidden';
483
+ dialog.setAttribute('role', 'dialog');
484
+ dialog.setAttribute('aria-modal', 'true');
485
+ dialog.setAttribute('aria-labelledby', 'client-menu-title');
486
+ document.body.appendChild(dialog);
487
+ return dialog;
488
+ }
489
+
490
+ function renderClientMenu(dialog, discordUrl = '') {
491
+ const current = currentPageName();
492
+ const theme = currentTheme();
493
+ const colorScheme = currentColorScheme();
494
+ dialog.innerHTML = `
495
+ <div class="client-menu-card">
496
+ <div class="client-menu-header">
497
+ <div>
498
+ <div class="client-menu-kicker">Bunnyland Clients</div>
499
+ <div id="client-menu-title" class="client-menu-title">Open a client or editor</div>
500
+ </div>
501
+ <button class="client-menu-close" type="button" aria-label="Close client menu">x</button>
502
+ </div>
503
+ <div class="client-menu-list">
504
+ ${CLIENT_MENU_ITEMS.map((item) => {
505
+ const active = item.href === current || (current === '' && item.href === 'index.html');
506
+ return `
507
+ <a class="client-menu-item ${active ? 'active' : ''}" href="${escapeHtml(clientHref(item))}"${clientTargetAttrs(item)}>
508
+ <span class="client-menu-item-main">
509
+ <span class="client-menu-item-title">${escapeHtml(item.title)}${item.admin ? '<span class="client-menu-admin-badge" title="Requires authentication" aria-label="Requires authentication">●</span>' : ''}</span>
510
+ <span class="client-menu-item-desc">${escapeHtml(item.description)}</span>
511
+ </span>
512
+ <span class="client-menu-item-label">${escapeHtml(active ? 'Current' : item.label)}</span>
513
+ </a>
514
+ `;
515
+ }).join('')}
516
+ </div>
517
+ <div class="client-menu-footer">
518
+ <label class="client-menu-theme" for="client-menu-theme-select">
519
+ <span>Palette</span>
520
+ <select id="client-menu-theme-select">
521
+ ${themeOptions().map(option => `
522
+ <option value="${escapeHtml(option.value)}" ${option.value === theme ? 'selected' : ''}>${escapeHtml(option.label)}</option>
523
+ `).join('')}
524
+ </select>
525
+ </label>
526
+ <label class="client-menu-theme" for="client-menu-color-scheme-select">
527
+ <span>Appearance</span>
528
+ <select id="client-menu-color-scheme-select">
529
+ ${colorSchemeOptions().map(option => `
530
+ <option value="${option.value}" ${option.value === colorScheme ? 'selected' : ''}>${option.label}</option>
531
+ `).join('')}
532
+ </select>
533
+ </label>
534
+ ${discordUrl ? `
535
+ <a class="client-menu-discord" href="${escapeHtml(discordUrl)}" target="_blank" rel="noopener">Join the Discord</a>
536
+ ` : ''}
537
+ <p class="client-menu-admin-note"><span class="client-menu-admin-badge">●</span> Admin tools require authentication.</p>
538
+ </div>
539
+ </div>
540
+ `;
541
+ dialog.querySelector('#client-menu-theme-select')?.addEventListener('change', (event) => {
542
+ setTheme(event.target.value);
543
+ renderClientMenu(dialog, discordUrl);
544
+ });
545
+ dialog.querySelector('#client-menu-color-scheme-select')?.addEventListener('change', (event) => {
546
+ setColorScheme(event.target.value);
547
+ renderClientMenu(dialog, discordUrl);
548
+ });
549
+ }
550
+
551
+ function openClientMenu() {
552
+ const dialog = ensureClientMenu();
553
+ renderClientMenu(dialog);
554
+ dialog.classList.remove('hidden');
555
+ const close = dialog.querySelector('.client-menu-close');
556
+ close?.focus();
557
+ // config.json arrives async; re-render in place once it does so the Discord link
558
+ // appears without blocking the menu from opening immediately.
559
+ loadConfig().then((config) => {
560
+ const url = typeof config?.discordUrl === 'string' ? config.discordUrl.trim() : '';
561
+ if (url && !dialog.classList.contains('hidden')) renderClientMenu(dialog, url);
562
+ });
563
+ }
564
+
565
+ function closeClientMenu() {
566
+ document.getElementById('client-menu-dialog')?.classList.add('hidden');
567
+ }
568
+
569
+ function initClientMenu({ baseUrl = '', buttonId = 'btn-client-menu', showOnFirstLoad = false } = {}) {
570
+ if (baseUrl) clientMenuBaseUrl = new URL(baseUrl, location.href).toString();
571
+ const button = document.getElementById(buttonId);
572
+ if (button) {
573
+ button.addEventListener('click', () => openClientMenu());
574
+ }
575
+
576
+ document.addEventListener('click', (event) => {
577
+ const dialog = document.getElementById('client-menu-dialog');
578
+ if (!dialog || dialog.classList.contains('hidden')) return;
579
+ if (event.target === dialog || event.target.closest('.client-menu-close')) {
580
+ closeClientMenu();
581
+ }
582
+ });
583
+ document.addEventListener('keydown', (event) => {
584
+ if (event.key === 'Escape') closeClientMenu();
585
+ });
586
+
587
+ if (showOnFirstLoad && storageGet(CLIENT_MENU_SEEN_KEY) !== '1') {
588
+ storageSet(CLIENT_MENU_SEEN_KEY, '1');
589
+ requestAnimationFrame(() => openClientMenu());
590
+ }
591
+
592
+ return { open: openClientMenu, close: closeClientMenu };
593
+ }
594
+
595
+ // Whether a keyboard event originated in a field the user is typing into, so a global
596
+ // shortcut like "?" never hijacks a real keystroke (e.g. typing a query into a filter).
597
+ function isEditableTarget(target) {
598
+ if (!target) return false;
599
+ const tag = target.tagName;
600
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable === true;
601
+ }
602
+
603
+ function ensureHelpDialog() {
604
+ let dialog = document.getElementById('help-dialog');
605
+ if (dialog) return dialog;
606
+ dialog = document.createElement('div');
607
+ dialog.id = 'help-dialog';
608
+ dialog.className = 'client-menu-backdrop hidden';
609
+ dialog.setAttribute('role', 'dialog');
610
+ dialog.setAttribute('aria-modal', 'true');
611
+ dialog.setAttribute('aria-labelledby', 'help-dialog-title');
612
+ document.body.appendChild(dialog);
613
+ return dialog;
614
+ }
615
+
616
+ function renderHelp(dialog, { title, intro, sections }) {
617
+ const sectionHtml = (sections || []).map((section) => `
618
+ <div class="help-section-title">${escapeHtml(section.title)}</div>
619
+ ${(section.items || []).map((item) => `
620
+ <div class="client-menu-item">
621
+ <span class="client-menu-item-main">
622
+ <span class="client-menu-item-title">${escapeHtml(item.label)}</span>
623
+ ${item.desc ? `<span class="client-menu-item-desc">${escapeHtml(item.desc)}</span>` : ''}
624
+ </span>
625
+ ${item.key ? `<span class="client-menu-item-label">${escapeHtml(item.key)}</span>` : ''}
626
+ </div>
627
+ `).join('')}
628
+ `).join('');
629
+ dialog.innerHTML = `
630
+ <div class="client-menu-card">
631
+ <div class="client-menu-header">
632
+ <div>
633
+ <div class="client-menu-kicker">Bunnyland</div>
634
+ <div id="help-dialog-title" class="client-menu-title">${escapeHtml(title || 'Controls & commands')}</div>
635
+ </div>
636
+ <button class="client-menu-close" type="button" aria-label="Close help">x</button>
637
+ </div>
638
+ <div class="client-menu-list">
639
+ ${intro ? `<p class="help-intro">${escapeHtml(intro)}</p>` : ''}
640
+ ${sectionHtml}
641
+ </div>
642
+ </div>
643
+ `;
644
+ }
645
+
646
+ // A shared help modal: the GUI clients (toon, web-tui) have no typed "help" command, so
647
+ // surface their controls and iconography here. Opens from a toolbar button and the "?"
648
+ // key (ignored while typing), matching the terminal TUI's "?" help binding.
649
+ function initHelp({ title, intro, sections = [], buttonId = 'btn-help' } = {}) {
650
+ const open = () => {
651
+ const dialog = ensureHelpDialog();
652
+ renderHelp(dialog, { title, intro, sections });
653
+ dialog.classList.remove('hidden');
654
+ dialog.querySelector('.client-menu-close')?.focus();
655
+ };
656
+ const close = () => document.getElementById('help-dialog')?.classList.add('hidden');
657
+
658
+ const button = document.getElementById(buttonId);
659
+ if (button) button.addEventListener('click', () => open());
660
+
661
+ document.addEventListener('click', (event) => {
662
+ const dialog = document.getElementById('help-dialog');
663
+ if (!dialog || dialog.classList.contains('hidden')) return;
664
+ if (event.target === dialog || event.target.closest('.client-menu-close')) close();
665
+ });
666
+ document.addEventListener('keydown', (event) => {
667
+ if (event.key === 'Escape') { close(); return; }
668
+ if (event.key !== '?' || isEditableTarget(event.target)) return;
669
+ event.preventDefault();
670
+ const dialog = document.getElementById('help-dialog');
671
+ if (dialog && !dialog.classList.contains('hidden')) close();
672
+ else open();
673
+ });
674
+
675
+ return { open, close };
676
+ }
677
+
678
+ function bindSearchDropdown(root, { options, value = '', onChange = null, emptyLabel = 'No matches' }) {
679
+ const input = root.querySelector('.search-dropdown-input');
680
+ const hidden = root.querySelector('.search-dropdown-value');
681
+ const menu = root.querySelector('.search-dropdown-menu');
682
+ const items = options.map(option => typeof option === 'string'
683
+ ? { value: option, label: option }
684
+ : { value: option.value, label: option.label || option.value });
685
+ let active = 0;
686
+
687
+ const setValue = (nextValue, notify = true) => {
688
+ const item = items.find(option => option.value === nextValue) || null;
689
+ hidden.value = item?.value || '';
690
+ input.value = item?.label || '';
691
+ if (notify && onChange) onChange(hidden.value, item);
692
+ };
693
+
694
+ const filteredItems = () => {
695
+ const q = input.value.trim().toLowerCase();
696
+ if (!q) return items;
697
+ return items.filter(item =>
698
+ item.label.toLowerCase().includes(q) ||
699
+ item.value.toLowerCase().includes(q)
700
+ );
701
+ };
702
+
703
+ const renderMenu = () => {
704
+ const filtered = filteredItems();
705
+ active = Math.max(0, Math.min(active, filtered.length - 1));
706
+ menu.innerHTML = filtered.length
707
+ ? filtered.map((item, index) => `
708
+ <div class="search-dropdown-option ${index === active ? 'active' : ''}" data-value="${escapeHtml(item.value)}">
709
+ ${escapeHtml(item.label)}
710
+ </div>
711
+ `).join('')
712
+ : `<div class="search-dropdown-empty">${escapeHtml(emptyLabel)}</div>`;
713
+ menu.classList.remove('hidden');
714
+ input.setAttribute('aria-expanded', 'true');
715
+ };
716
+
717
+ const chooseActive = () => {
718
+ const item = filteredItems()[active];
719
+ if (!item) return;
720
+ setValue(item.value);
721
+ menu.classList.add('hidden');
722
+ input.setAttribute('aria-expanded', 'false');
723
+ };
724
+
725
+ input.addEventListener('input', () => {
726
+ active = 0;
727
+ renderMenu();
728
+ });
729
+ input.addEventListener('focus', () => renderMenu());
730
+ input.addEventListener('keydown', (event) => {
731
+ if (event.key === 'ArrowDown') {
732
+ event.preventDefault();
733
+ active += 1;
734
+ renderMenu();
735
+ } else if (event.key === 'ArrowUp') {
736
+ event.preventDefault();
737
+ active -= 1;
738
+ renderMenu();
739
+ } else if (event.key === 'Enter') {
740
+ event.preventDefault();
741
+ chooseActive();
742
+ } else if (event.key === 'Escape') {
743
+ menu.classList.add('hidden');
744
+ input.setAttribute('aria-expanded', 'false');
745
+ input.blur();
746
+ }
747
+ });
748
+ input.addEventListener('blur', () => {
749
+ setTimeout(() => {
750
+ menu.classList.add('hidden');
751
+ input.setAttribute('aria-expanded', 'false');
752
+ }, 150);
753
+ });
754
+ menu.addEventListener('mousedown', (event) => {
755
+ const option = event.target.closest('.search-dropdown-option');
756
+ if (!option) return;
757
+ event.preventDefault();
758
+ setValue(option.dataset.value);
759
+ menu.classList.add('hidden');
760
+ input.setAttribute('aria-expanded', 'false');
761
+ });
762
+
763
+ setValue(value, false);
764
+ return { setValue };
765
+ }
766
+
767
+ window.BunnylandUI = {
768
+ bindColorSchemeSelect,
769
+ bindTagEditor,
770
+ bindSearchDropdown,
771
+ bindThemeSelect,
772
+ cloneJson,
773
+ colorSchemeOptions,
774
+ currentColorScheme,
775
+ escapeHtml,
776
+ currentTheme,
777
+ initClientMenu,
778
+ initHelp,
779
+ initTheme,
780
+ loadConfig,
781
+ normalizeTags,
782
+ normalizeTheme,
783
+ registerThemeOption,
784
+ registerThemeOptions,
785
+ renderTagEditorTags,
786
+ setTheme,
787
+ setColorScheme,
788
+ tagEditorHtml,
789
+ themeFromSearch,
790
+ themeOptions,
791
+ };
792
+
793
+ initTheme();
794
+ loadConfig();
795
+ }());