@bunnyland/ui-web 0.2.1 → 0.2.3

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.
@@ -196,15 +196,19 @@
196
196
  }
197
197
 
198
198
  async function promptPlayerAuth(base) {
199
- const username = window.prompt('Bunnyland username');
200
- if (!username) return null;
201
- const password = window.prompt('Bunnyland password');
202
- if (password == null) return null;
199
+ const credentials = await window.BunnylandUI.credentialsDialog({
200
+ message: 'Enter your player credentials to continue.',
201
+ title: 'Sign in to Bunnyland',
202
+ });
203
+ if (!credentials) return null;
203
204
  try {
204
- await login(base, username, password);
205
+ await login(base, credentials.username, credentials.password);
205
206
  return true;
206
207
  } catch (error) {
207
- window.alert(error.message || 'Login failed');
208
+ await window.BunnylandUI.alertDialog(error.message || 'Login failed', {
209
+ title: 'Sign in failed',
210
+ tone: 'danger',
211
+ });
208
212
  return false;
209
213
  }
210
214
  }
@@ -9,9 +9,11 @@
9
9
  --bl-font-mono: 'Courier New', monospace;
10
10
  --bl-radius-sm: 3px;
11
11
  --bl-radius-md: 4px;
12
+ --bl-radius-lg: 6px;
12
13
  --bl-text-xs: 10px;
13
14
  --bl-text-sm: 11px;
14
15
  --bl-text-md: 12px;
16
+ --bl-text-xl: 18px;
15
17
 
16
18
  --bl-bg: #1e1e2e;
17
19
  --bl-bg-strong: #181825;
@@ -36,6 +38,8 @@
36
38
  --bl-ok: #a6e3a1;
37
39
  --bl-error: #f38ba8;
38
40
  --bl-warn: #fab387;
41
+ --bl-danger: var(--bl-error);
42
+ --bl-warning: var(--bl-warn);
39
43
  --bl-info: #74c7ec;
40
44
 
41
45
  --bl-shadow-popover: 0 4px 12px rgba(0, 0, 0, 0.5);
@@ -645,6 +649,7 @@ input, select, textarea, button { font-family: var(--bl-font-mono); }
645
649
 
646
650
  input[type=file],
647
651
  input[type=text],
652
+ input[type=password],
648
653
  input[type=number],
649
654
  select,
650
655
  textarea {
@@ -662,10 +667,14 @@ input[type=file] {
662
667
  }
663
668
 
664
669
  input:focus, select:focus, textarea:focus {
665
- outline: none;
666
670
  border-color: var(--bl-accent);
667
671
  }
668
672
 
673
+ :where(a, button, input, select, textarea, [tabindex]):focus-visible {
674
+ outline: 2px solid var(--bl-accent);
675
+ outline-offset: 2px;
676
+ }
677
+
669
678
  button {
670
679
  padding: 3px 10px;
671
680
  background: var(--bl-surface);
@@ -681,6 +690,31 @@ button {
681
690
  button:hover:not(:disabled) { background: var(--bl-surface-hover); }
682
691
  button:disabled { opacity: 0.4; cursor: default; }
683
692
 
693
+ dialog {
694
+ position: fixed;
695
+ z-index: 10000;
696
+ inset: 0;
697
+ width: min(460px, calc(100vw - 24px));
698
+ max-width: calc(100vw - 24px);
699
+ max-height: calc(100dvh - 24px);
700
+ margin: auto;
701
+ padding: 14px;
702
+ overflow: auto;
703
+ border: 1px solid var(--bl-border-control);
704
+ border-radius: var(--bl-radius-md);
705
+ color: var(--bl-text);
706
+ background: var(--bl-bg-strong);
707
+ box-shadow: var(--bl-shadow-popover);
708
+ }
709
+
710
+ dialog::backdrop { background: var(--bl-overlay); }
711
+
712
+ /* The Toon client and browser fallbacks can render with the open attribute
713
+ instead of showModal. Keep those dialogs modal-looking. */
714
+ dialog[open]:not(:modal):not(.content-warning-dialog) {
715
+ box-shadow: 0 0 0 100vmax var(--bl-overlay), var(--bl-shadow-popover);
716
+ }
717
+
684
718
  .bl-button-primary {
685
719
  color: var(--bl-text-inverse);
686
720
  background: var(--bl-accent);
@@ -718,8 +752,6 @@ button:disabled { opacity: 0.4; cursor: default; }
718
752
  box-shadow: var(--bl-shadow-popover);
719
753
  }
720
754
 
721
- .bl-auth-dialog::backdrop { background: var(--bl-overlay); }
722
-
723
755
  .bl-auth-form,
724
756
  .bl-auth-form label {
725
757
  display: grid;
@@ -743,6 +775,72 @@ button:disabled { opacity: 0.4; cursor: default; }
743
775
  gap: 8px;
744
776
  }
745
777
 
778
+ .bl-dialog {
779
+ width: min(460px, calc(100vw - 24px));
780
+ max-height: calc(100dvh - 24px);
781
+ overflow: hidden;
782
+ padding: 0;
783
+ border: 1px solid var(--bl-border-control);
784
+ border-radius: var(--bl-radius-md);
785
+ color: var(--bl-text);
786
+ background: var(--bl-bg-strong);
787
+ box-shadow: var(--bl-shadow-popover);
788
+ }
789
+
790
+ .bl-dialog-form {
791
+ display: flex;
792
+ flex-direction: column;
793
+ max-height: calc(100dvh - 24px);
794
+ }
795
+
796
+ .bl-dialog-header,
797
+ .bl-dialog-body,
798
+ .bl-dialog-actions {
799
+ padding: 14px;
800
+ }
801
+
802
+ .bl-dialog-header {
803
+ border-bottom: 1px solid var(--bl-border);
804
+ background: var(--bl-bg);
805
+ }
806
+
807
+ .bl-dialog-header h2 {
808
+ margin: 0;
809
+ color: var(--bl-text);
810
+ font-size: var(--bl-text-xl);
811
+ }
812
+
813
+ .bl-dialog-body {
814
+ display: grid;
815
+ gap: 12px;
816
+ overflow-y: auto;
817
+ }
818
+
819
+ .bl-dialog-message {
820
+ margin: 0;
821
+ color: var(--bl-text-soft);
822
+ font-size: var(--bl-text-md);
823
+ line-height: 1.5;
824
+ overflow-wrap: anywhere;
825
+ white-space: pre-wrap;
826
+ }
827
+
828
+ .bl-dialog-field {
829
+ display: grid;
830
+ gap: 5px;
831
+ font-size: var(--bl-text-sm);
832
+ }
833
+
834
+ .bl-dialog-field input { width: 100%; }
835
+
836
+ .bl-dialog-actions {
837
+ display: flex;
838
+ justify-content: flex-end;
839
+ gap: 8px;
840
+ border-top: 1px solid var(--bl-border);
841
+ background: var(--bl-bg);
842
+ }
843
+
746
844
  #toolbar {
747
845
  display: flex;
748
846
  flex-direction: column;
@@ -910,6 +1008,19 @@ button:disabled { opacity: 0.4; cursor: default; }
910
1008
  padding: 6px;
911
1009
  }
912
1010
 
1011
+ .client-menu-section-title {
1012
+ padding: 10px 10px 3px;
1013
+ color: var(--bl-accent-strong);
1014
+ font-size: var(--bl-text-xs);
1015
+ font-weight: bold;
1016
+ letter-spacing: 0.5px;
1017
+ text-transform: uppercase;
1018
+ }
1019
+
1020
+ .client-menu-section-title:first-child {
1021
+ padding-top: 4px;
1022
+ }
1023
+
913
1024
  .client-menu-item {
914
1025
  display: grid;
915
1026
  grid-template-columns: minmax(0, 1fr) auto;
@@ -952,6 +1063,18 @@ button:disabled { opacity: 0.4; cursor: default; }
952
1063
  margin-left: 6px;
953
1064
  }
954
1065
 
1066
+ .client-menu-recommended {
1067
+ display: inline-block;
1068
+ margin-left: 7px;
1069
+ padding: 1px 5px;
1070
+ border: 1px solid var(--bl-ok);
1071
+ border-radius: var(--bl-radius-sm);
1072
+ color: var(--bl-ok);
1073
+ font-size: var(--bl-text-xs);
1074
+ font-weight: normal;
1075
+ vertical-align: middle;
1076
+ }
1077
+
955
1078
  .client-menu-item-desc {
956
1079
  color: var(--bl-text-soft);
957
1080
  font-size: var(--bl-text-sm);
@@ -1589,6 +1712,17 @@ textarea {
1589
1712
  min-height: 30px;
1590
1713
  }
1591
1714
 
1715
+ :is(.bl-page-welcome, .bl-page-toon, .bl-page-character, .bl-page-web-tui, .bl-page-web-repl) {
1716
+ --bl-text-xs: 12px;
1717
+ --bl-text-sm: 14px;
1718
+ --bl-text-md: 16px;
1719
+ }
1720
+
1721
+ :is(.bl-page-welcome, .bl-page-toon, .bl-page-character, .bl-page-web-tui, .bl-page-web-repl)
1722
+ :is(button, input[type=file], input[type=text], input[type=number], select, .button-link) {
1723
+ min-height: 44px;
1724
+ }
1725
+
1592
1726
  .client-menu-backdrop {
1593
1727
  padding: 12px;
1594
1728
  }
@@ -34,6 +34,7 @@
34
34
  const boundThemeSelects = new Set();
35
35
  const boundColorSchemeSelects = new Set();
36
36
  const CLIENT_MENU_SEEN_KEY = 'bunnyland.clientMenu.seen';
37
+ let actionDialogQueue = Promise.resolve();
37
38
  let clientMenuBaseUrl = '';
38
39
  // Admin tools order: World Generator, World Graph, editor tools alphabetically, then miscellaneous tools.
39
40
  const CLIENT_MENU_ITEMS = [
@@ -42,6 +43,7 @@
42
43
  title: 'Welcome',
43
44
  label: 'Start here',
44
45
  description: 'Project overview, docs, admin notes, and client chooser.',
46
+ group: 'Bunnyland',
45
47
  supportsServer: true,
46
48
  },
47
49
  {
@@ -49,21 +51,25 @@
49
51
  title: 'Bunnyland.dev',
50
52
  label: 'Website',
51
53
  description: 'Project homepage, feature overview, guides, and public docs.',
54
+ group: 'Bunnyland',
52
55
  supportsServer: false,
53
56
  },
54
- {
55
- href: 'toon-client.html',
56
- title: 'Toon Client',
57
- label: 'Player room view',
58
- description: 'Claim a character and play from the room-focused visual client.',
59
- supportsFocus: true,
60
- supportsServer: true,
61
- },
62
57
  {
63
58
  href: 'web-tui.html',
64
59
  title: 'Web TUI',
65
60
  label: 'Player action menu',
66
61
  description: 'Claim a character and play from the terminal TUI-style browser client.',
62
+ group: 'Player clients',
63
+ recommended: true,
64
+ supportsFocus: true,
65
+ supportsServer: true,
66
+ },
67
+ {
68
+ href: 'toon-client.html',
69
+ title: 'Toon Client',
70
+ label: 'Player room view',
71
+ description: 'Claim a character and play from the room-focused visual client.',
72
+ group: 'Player clients',
67
73
  supportsFocus: true,
68
74
  supportsServer: true,
69
75
  },
@@ -72,6 +78,7 @@
72
78
  title: 'Web REPL',
73
79
  label: 'Text-based play',
74
80
  description: 'Claim a character and play with typed commands in the browser.',
81
+ group: 'Player clients',
75
82
  supportsFocus: true,
76
83
  supportsServer: true,
77
84
  },
@@ -80,6 +87,7 @@
80
87
  title: 'Character Profile',
81
88
  label: 'Profile and chat',
82
89
  description: 'Open a character profile, inspect live state, and chat in character.',
90
+ group: 'Player clients',
83
91
  supportsFocus: true,
84
92
  supportsServer: true,
85
93
  },
@@ -88,6 +96,7 @@
88
96
  title: 'World Generator',
89
97
  label: 'Admin generator',
90
98
  description: 'Generate or replace a live world using enabled server generators.',
99
+ group: 'Builder & admin',
91
100
  supportsServer: true,
92
101
  admin: true,
93
102
  },
@@ -96,6 +105,7 @@
96
105
  title: 'World Graph',
97
106
  label: 'Graph editor',
98
107
  description: 'Browse and extend the ECS world graph from a snapshot or live server.',
108
+ group: 'Builder & admin',
99
109
  supportsServer: true,
100
110
  admin: true,
101
111
  },
@@ -104,6 +114,7 @@
104
114
  title: 'Behavior Editor',
105
115
  label: 'Behavior trees',
106
116
  description: 'Author behavior-tree JSON for behavioral controllers and register it live.',
117
+ group: 'Builder & admin',
107
118
  supportsServer: true,
108
119
  admin: true,
109
120
  },
@@ -112,6 +123,7 @@
112
123
  title: 'Memory Editor',
113
124
  label: 'Memory editor',
114
125
  description: 'Inspect and edit character memory collections on a live server.',
126
+ group: 'Builder & admin',
115
127
  supportsServer: true,
116
128
  admin: true,
117
129
  },
@@ -120,6 +132,7 @@
120
132
  title: 'Script Editor',
121
133
  label: 'Automation scripts',
122
134
  description: 'Create and validate script JSON blocks against a snapshot.',
135
+ group: 'Builder & admin',
123
136
  supportsServer: false,
124
137
  admin: true,
125
138
  },
@@ -128,6 +141,7 @@
128
141
  title: 'World Editor',
129
142
  label: 'Admin editor',
130
143
  description: 'Edit entities, components, relationships, fragments, and live snapshots.',
144
+ group: 'Builder & admin',
131
145
  supportsServer: true,
132
146
  admin: true,
133
147
  },
@@ -136,6 +150,7 @@
136
150
  title: 'Event Stream',
137
151
  label: 'Event viewer',
138
152
  description: 'Watch the live world event feed with expandable records and entity references.',
153
+ group: 'Builder & admin',
139
154
  supportsServer: true,
140
155
  admin: true,
141
156
  },
@@ -144,6 +159,7 @@
144
159
  title: 'Trace Analyzer',
145
160
  label: 'Trace inspection',
146
161
  description: 'Inspect live Tempo traces or load JSON and JSONL trace artifacts.',
162
+ group: 'Builder & admin',
147
163
  supportsServer: true,
148
164
  admin: true,
149
165
  },
@@ -476,6 +492,138 @@
476
492
  return url.origin === location.origin ? '' : ' target="_blank" rel="noopener"';
477
493
  }
478
494
 
495
+ function ensureActionDialog() {
496
+ let dialog = document.getElementById('bl-action-dialog');
497
+ if (dialog) return dialog;
498
+ dialog = document.createElement('dialog');
499
+ dialog.id = 'bl-action-dialog';
500
+ dialog.className = 'bl-dialog';
501
+ dialog.setAttribute('aria-labelledby', 'bl-action-dialog-title');
502
+ document.body.appendChild(dialog);
503
+ return dialog;
504
+ }
505
+
506
+ function actionDialog({
507
+ cancelLabel = 'Cancel',
508
+ cancellable = true,
509
+ confirmLabel = 'OK',
510
+ fields = [],
511
+ message = '',
512
+ title = 'Bunnyland',
513
+ tone = 'default',
514
+ } = {}) {
515
+ const open = () => new Promise((resolve) => {
516
+ const dialog = ensureActionDialog();
517
+ const previousFocus = document.activeElement;
518
+ dialog.innerHTML = `
519
+ <form class="bl-dialog-form" method="dialog">
520
+ <div class="bl-dialog-header"><h2 id="bl-action-dialog-title">${escapeHtml(title)}</h2></div>
521
+ <div class="bl-dialog-body">
522
+ ${message ? `<p class="bl-dialog-message">${escapeHtml(message)}</p>` : ''}
523
+ ${fields.map((field, index) => `
524
+ <label class="bl-dialog-field">
525
+ <span>${escapeHtml(field.label || field.name || `Value ${index + 1}`)}</span>
526
+ <input name="${escapeHtml(field.name || `field-${index}`)}"
527
+ type="${escapeHtml(field.type || 'text')}"
528
+ value="${escapeHtml(field.value || '')}"
529
+ ${field.autocomplete ? `autocomplete="${escapeHtml(field.autocomplete)}"` : ''}
530
+ ${field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : ''}
531
+ ${field.required ? 'required' : ''}>
532
+ </label>
533
+ `).join('')}
534
+ </div>
535
+ <div class="bl-dialog-actions">
536
+ ${cancellable ? `<button class="bl-dialog-cancel" type="button">${escapeHtml(cancelLabel)}</button>` : ''}
537
+ <button class="bl-dialog-confirm ${tone === 'danger' ? 'bl-button-danger' : 'bl-button-primary'}" type="submit">${escapeHtml(confirmLabel)}</button>
538
+ </div>
539
+ </form>
540
+ `;
541
+ const form = dialog.querySelector('.bl-dialog-form');
542
+ const cancel = dialog.querySelector('.bl-dialog-cancel');
543
+ let settled = false;
544
+
545
+ const finish = (value) => {
546
+ if (settled) return;
547
+ settled = true;
548
+ dialog.removeEventListener('cancel', onCancel);
549
+ dialog.removeEventListener('click', onBackdropClick);
550
+ if (dialog.open && typeof dialog.close === 'function') dialog.close();
551
+ else dialog.removeAttribute('open');
552
+ previousFocus?.focus?.();
553
+ resolve(value);
554
+ };
555
+ const onCancel = (event) => {
556
+ event.preventDefault();
557
+ if (cancellable) finish(null);
558
+ };
559
+ const onBackdropClick = (event) => {
560
+ if (cancellable && event.target === dialog) finish(null);
561
+ };
562
+
563
+ form.addEventListener('submit', (event) => {
564
+ event.preventDefault();
565
+ const values = {};
566
+ for (const field of fields) {
567
+ const input = form.elements.namedItem(field.name);
568
+ values[field.name] = input?.value || '';
569
+ }
570
+ finish(values);
571
+ });
572
+ cancel?.addEventListener('click', () => finish(null));
573
+ dialog.addEventListener('cancel', onCancel);
574
+ dialog.addEventListener('click', onBackdropClick);
575
+ if (typeof dialog.showModal === 'function') dialog.showModal();
576
+ else dialog.setAttribute('open', '');
577
+ (dialog.querySelector('input') || dialog.querySelector('.bl-dialog-confirm'))?.focus();
578
+ });
579
+ const queued = actionDialogQueue.then(open, open);
580
+ actionDialogQueue = queued.then(() => undefined, () => undefined);
581
+ return queued;
582
+ }
583
+
584
+ async function promptDialog(message, options = {}) {
585
+ const fieldName = 'value';
586
+ const values = await actionDialog({
587
+ ...options,
588
+ fields: [{
589
+ autocomplete: options.autocomplete,
590
+ label: options.label || 'Value',
591
+ name: fieldName,
592
+ placeholder: options.placeholder,
593
+ required: options.required,
594
+ type: options.type || 'text',
595
+ value: options.value || '',
596
+ }],
597
+ message,
598
+ });
599
+ return values ? values[fieldName] : null;
600
+ }
601
+
602
+ async function confirmDialog(message, options = {}) {
603
+ return Boolean(await actionDialog({
604
+ ...options,
605
+ confirmLabel: options.confirmLabel || 'Confirm',
606
+ fields: [],
607
+ message,
608
+ }));
609
+ }
610
+
611
+ async function credentialsDialog(options = {}) {
612
+ return actionDialog({
613
+ ...options,
614
+ confirmLabel: options.confirmLabel || 'Sign in',
615
+ fields: [
616
+ { autocomplete: 'username', label: options.usernameLabel || 'Username', name: 'username', required: true },
617
+ { autocomplete: 'current-password', label: options.passwordLabel || 'Password', name: 'password', required: true, type: 'password' },
618
+ ],
619
+ title: options.title || 'Sign in to Bunnyland',
620
+ });
621
+ }
622
+
623
+ async function alertDialog(message, options = {}) {
624
+ await actionDialog({ ...options, cancellable: false, fields: [], message });
625
+ }
626
+
479
627
  function ensureClientMenu() {
480
628
  let dialog = document.getElementById('client-menu-dialog');
481
629
  if (dialog) return dialog;
@@ -504,12 +652,14 @@
504
652
  <button class="client-menu-close" type="button" aria-label="Close client menu">x</button>
505
653
  </div>
506
654
  <div class="client-menu-list">
507
- ${CLIENT_MENU_ITEMS.map((item) => {
655
+ ${CLIENT_MENU_ITEMS.map((item, index) => {
508
656
  const active = item.href === current || (current === '' && item.href === 'index.html');
657
+ const previousGroup = CLIENT_MENU_ITEMS[index - 1]?.group;
509
658
  return `
659
+ ${item.group !== previousGroup ? `<div class="client-menu-section-title">${escapeHtml(item.group)}</div>` : ''}
510
660
  <a class="client-menu-item ${active ? 'active' : ''}" href="${escapeHtml(clientHref(item))}"${clientTargetAttrs(item)}>
511
661
  <span class="client-menu-item-main">
512
- <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>
662
+ <span class="client-menu-item-title">${escapeHtml(item.title)}${item.recommended ? '<span class="client-menu-recommended">Recommended</span>' : ''}${item.admin ? '<span class="client-menu-admin-badge" title="Requires authentication" aria-label="Requires authentication">●</span>' : ''}</span>
513
663
  <span class="client-menu-item-desc">${escapeHtml(item.description)}</span>
514
664
  </span>
515
665
  <span class="client-menu-item-label">${escapeHtml(active ? 'Current' : item.label)}</span>
@@ -768,6 +918,7 @@
768
918
  }
769
919
 
770
920
  window.BunnylandUI = {
921
+ alertDialog,
771
922
  bindColorSchemeSelect,
772
923
  bindTagEditor,
773
924
  bindSearchDropdown,
@@ -777,12 +928,15 @@
777
928
  currentColorScheme,
778
929
  escapeHtml,
779
930
  currentTheme,
931
+ confirmDialog,
932
+ credentialsDialog,
780
933
  initClientMenu,
781
934
  initHelp,
782
935
  initTheme,
783
936
  loadConfig,
784
937
  normalizeTags,
785
938
  normalizeTheme,
939
+ promptDialog,
786
940
  registerThemeOption,
787
941
  registerThemeOptions,
788
942
  renderTagEditorTags,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunnyland/ui-web",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "Shared web UI helpers, themes, and widgets for Bunnyland browser clients.",
6
6
  "license": "AGPL-3.0-or-later",