@unson/brainbase-mcp 0.41.8 → 0.41.10
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/canonical-task-service.d.ts +6 -0
- package/dist/canonical-task-service.js +18 -1
- package/dist/foundation-graph-http.js +5 -1
- package/dist/graph-foundation-reader.d.ts +27 -2
- package/dist/graph-foundation-reader.js +97 -1
- package/dist/meeting-minutes-http.js +19 -0
- package/dist/meeting-minutes.js +4 -0
- package/package.json +2 -2
- package/ui/foundation-overview.js +70 -14
- package/ui/meeting-minutes.css +151 -4
- package/ui/meeting-minutes.js +416 -94
- package/ui/workspace-kit.css +42 -0
- package/ui/workspace-kit.js +165 -0
- package/ui/world/world-canvas-notes.js +2 -1
- package/ui/world/world-canvas-ui.css +48 -3
- package/ui/world/world-canvas-ui.js +17 -4
- package/ui/world/world-district.js +34 -5
- package/ui/world/world-view.css +6 -0
- package/ui/world/world-view.js +109 -19
package/ui/workspace-kit.css
CHANGED
|
@@ -573,3 +573,45 @@
|
|
|
573
573
|
.bb-ws-drawer[open], .bb-ws-drawer.is-closing, .bb-ws-drawer.is-closing::backdrop,
|
|
574
574
|
.bb-ws-filter-popover, .bb-ws-loading-spinner::before, .bb-ws-error-card.is-dismissing { animation: none; }
|
|
575
575
|
}
|
|
576
|
+
|
|
577
|
+
/* Searchable dropdown (workspaceCombobox) */
|
|
578
|
+
.bb-ws-combobox { position: relative; display: inline-grid; min-width: 0; }
|
|
579
|
+
.bb-ws-combobox-trigger {
|
|
580
|
+
display: flex; align-items: center; justify-content: space-between; gap: var(--bb-space-2);
|
|
581
|
+
width: 100%; min-width: 0; min-height: 38px; box-sizing: border-box; padding: 0 10px 0 11px;
|
|
582
|
+
border: 1px solid var(--bb-color-border-strong); border-radius: var(--bb-radius-sm);
|
|
583
|
+
background: var(--bb-color-panel); color: var(--bb-color-ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer;
|
|
584
|
+
}
|
|
585
|
+
.bb-ws-combobox-trigger:hover { border-color: var(--bb-color-muted); }
|
|
586
|
+
.bb-ws-combobox-trigger:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 1px; }
|
|
587
|
+
.bb-ws-combobox-trigger[aria-expanded='true'] { border-color: var(--bb-color-accent); }
|
|
588
|
+
.bb-ws-combobox-trigger[disabled] { color: var(--bb-color-quiet); cursor: not-allowed; }
|
|
589
|
+
.bb-ws-combobox-trigger[data-empty='true'] .bb-ws-combobox-value { color: var(--bb-color-quiet); }
|
|
590
|
+
.bb-ws-combobox-value { overflow: hidden; min-width: 0; text-overflow: ellipsis; white-space: nowrap; }
|
|
591
|
+
.bb-ws-combobox-chevron::after { content: '⌄'; color: var(--bb-color-muted); }
|
|
592
|
+
.bb-ws-combobox-popup {
|
|
593
|
+
position: absolute; z-index: 45; top: calc(100% + 6px); left: 0; display: grid; gap: var(--bb-space-2);
|
|
594
|
+
box-sizing: border-box; width: max(100%, 260px); max-width: calc(100vw - 32px); padding: 8px;
|
|
595
|
+
border: 1px solid var(--bb-color-border); border-radius: var(--bb-radius-md); background: var(--bb-color-panel); color: var(--bb-color-ink);
|
|
596
|
+
box-shadow: var(--bb-shadow-popover); animation: bb-ws-filter-enter 140ms ease-out; transform-origin: top left;
|
|
597
|
+
}
|
|
598
|
+
.bb-ws-combobox-popup[hidden], .bb-ws-combobox-search[hidden] { display: none !important; }
|
|
599
|
+
.bb-ws-combobox-search {
|
|
600
|
+
box-sizing: border-box; width: 100%; min-height: 34px; padding: 0 10px;
|
|
601
|
+
border: 1px solid var(--bb-color-border-strong); border-radius: var(--bb-radius-sm); background: var(--bb-color-surface); color: var(--bb-color-ink); font: inherit; font-size: 13px;
|
|
602
|
+
}
|
|
603
|
+
.bb-ws-combobox-search:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 0; }
|
|
604
|
+
.bb-ws-combobox-list { max-height: 300px; margin: 0; padding: 0; overflow-y: auto; list-style: none; }
|
|
605
|
+
.bb-ws-combobox-list:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 0; }
|
|
606
|
+
.bb-ws-combobox-option {
|
|
607
|
+
display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: var(--bb-space-2);
|
|
608
|
+
padding: 7px 8px; border-radius: var(--bb-radius-sm); font-size: 13px; cursor: pointer;
|
|
609
|
+
}
|
|
610
|
+
.bb-ws-combobox-option::before { content: ''; color: var(--bb-color-accent-strong); font-weight: 700; }
|
|
611
|
+
.bb-ws-combobox-option[aria-selected='true']::before { content: '✓'; }
|
|
612
|
+
.bb-ws-combobox-option:hover, .bb-ws-combobox-option.is-active { background: var(--bb-color-accent-soft); }
|
|
613
|
+
.bb-ws-combobox-option.is-active { box-shadow: inset 2px 0 0 var(--bb-color-accent); }
|
|
614
|
+
.bb-ws-combobox-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
615
|
+
.bb-ws-combobox-option-hint { color: var(--bb-color-muted); font-size: 12px; white-space: nowrap; }
|
|
616
|
+
.bb-ws-combobox-status { margin: 0; padding: 2px 4px 0; color: var(--bb-color-muted); font-size: 12px; }
|
|
617
|
+
@media (prefers-reduced-motion:reduce) { .bb-ws-combobox-popup { animation: none; } }
|
package/ui/workspace-kit.js
CHANGED
|
@@ -422,3 +422,168 @@ export function workspaceDisclosure(doc, { title, help, content, open = false, o
|
|
|
422
422
|
if (onToggle) details.addEventListener('toggle', () => onToggle(details.open));
|
|
423
423
|
return details;
|
|
424
424
|
}
|
|
425
|
+
|
|
426
|
+
function comboboxText(value) {
|
|
427
|
+
return String(value ?? '').normalize('NFKC').toLocaleLowerCase('ja');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Dropdown whose list can be searched. The trigger shows the chosen label;
|
|
432
|
+
* opening it moves focus to a search box (role=combobox) that narrows a
|
|
433
|
+
* listbox. Arrow keys move the active option, Enter chooses it, Escape closes
|
|
434
|
+
* and returns focus to the trigger, and typing on the trigger starts a search.
|
|
435
|
+
* Long lists render the first `limit` matches and say how many the search
|
|
436
|
+
* would still reveal. Options are `{ value, label, hint?, keywords? }`; the
|
|
437
|
+
* host owns the chosen value and receives it through `onChange`.
|
|
438
|
+
*/
|
|
439
|
+
export function workspaceCombobox(doc, {
|
|
440
|
+
label, options = [], value = '', placeholder = '選択してください', searchLabel, searchPlaceholder = '絞り込む',
|
|
441
|
+
emptyText = '一致する候補はありません', limit = 100, searchable = 'auto', className, onChange,
|
|
442
|
+
} = {}) {
|
|
443
|
+
const sequence = ++workspaceSequence;
|
|
444
|
+
const listId = `bb-ws-combobox-${sequence}-list`;
|
|
445
|
+
const root = el(doc, 'span', { className: ['bb-ws-combobox', className].filter(Boolean).join(' ') });
|
|
446
|
+
const trigger = el(doc, 'button', { className: 'bb-ws-combobox-trigger', attrs: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false', 'aria-controls': listId } });
|
|
447
|
+
const triggerText = el(doc, 'span', { className: 'bb-ws-combobox-value' });
|
|
448
|
+
trigger.append(triggerText, el(doc, 'span', { className: 'bb-ws-combobox-chevron', attrs: { 'aria-hidden': 'true' } }));
|
|
449
|
+
const popup = el(doc, 'div', { className: 'bb-ws-combobox-popup' });
|
|
450
|
+
popup.hidden = true;
|
|
451
|
+
const search = el(doc, 'input', { className: 'bb-ws-combobox-search', attrs: {
|
|
452
|
+
type: 'text', role: 'combobox', 'aria-autocomplete': 'list', 'aria-expanded': 'true', 'aria-controls': listId,
|
|
453
|
+
'aria-label': searchLabel || `${label || '候補'}を絞り込む`, placeholder: searchPlaceholder, autocomplete: 'off', spellcheck: 'false',
|
|
454
|
+
} });
|
|
455
|
+
const list = el(doc, 'ul', { className: 'bb-ws-combobox-list', attrs: { id: listId, role: 'listbox', 'aria-label': label || '候補', tabindex: '-1' } });
|
|
456
|
+
const status = el(doc, 'p', { className: 'bb-ws-combobox-status', attrs: { role: 'status', 'aria-live': 'polite' } });
|
|
457
|
+
popup.append(search, list, status);
|
|
458
|
+
root.append(trigger, popup);
|
|
459
|
+
|
|
460
|
+
const state = { options: [], value: String(value ?? ''), query: '', active: -1, shown: [], open: false, disabled: false };
|
|
461
|
+
const usesSearch = () => searchable === true || (searchable === 'auto' && state.options.length > 8);
|
|
462
|
+
const focusTarget = () => (usesSearch() ? search : list);
|
|
463
|
+
const chosen = () => state.options.find(option => option.value === state.value);
|
|
464
|
+
|
|
465
|
+
function setOptions(next) {
|
|
466
|
+
state.options = (Array.isArray(next) ? next : []).map(option => ({
|
|
467
|
+
value: String(option?.value ?? ''), label: String(option?.label ?? option?.value ?? ''),
|
|
468
|
+
hint: option?.hint ? String(option.hint) : '', keywords: option?.keywords ? String(option.keywords) : '',
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderTrigger() {
|
|
473
|
+
const option = chosen();
|
|
474
|
+
// A value the list does not offer stays visible instead of looking unset.
|
|
475
|
+
const text = option ? option.label : state.value || placeholder;
|
|
476
|
+
triggerText.textContent = text;
|
|
477
|
+
trigger.setAttribute('aria-label', `${label ? `${label}: ` : ''}${text}`);
|
|
478
|
+
trigger.setAttribute('data-empty', String(!option && !state.value));
|
|
479
|
+
if (state.disabled) trigger.setAttribute('disabled', ''); else trigger.removeAttribute('disabled');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function activeDescendant(node) {
|
|
483
|
+
for (const target of [search, list]) {
|
|
484
|
+
if (node) target.setAttribute('aria-activedescendant', node.getAttribute('id'));
|
|
485
|
+
else target.removeAttribute('aria-activedescendant');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderList() {
|
|
490
|
+
const terms = comboboxText(state.query).split(/\s+/u).filter(Boolean);
|
|
491
|
+
const matches = terms.length
|
|
492
|
+
? state.options.filter(option => { const text = comboboxText(`${option.label}\n${option.hint}\n${option.keywords}`); return terms.every(term => text.includes(term)); })
|
|
493
|
+
: state.options;
|
|
494
|
+
const max = Math.max(1, Number(limit) || 100);
|
|
495
|
+
state.shown = matches.slice(0, max);
|
|
496
|
+
if (state.active >= state.shown.length) state.active = state.shown.length - 1;
|
|
497
|
+
list.replaceChildren();
|
|
498
|
+
state.shown.forEach((option, index) => {
|
|
499
|
+
const selected = option.value === state.value;
|
|
500
|
+
const item = el(doc, 'li', { className: `bb-ws-combobox-option${index === state.active ? ' is-active' : ''}`, attrs: { id: `bb-ws-combobox-${sequence}-option-${index}`, role: 'option', 'aria-selected': String(selected), 'data-value': option.value } });
|
|
501
|
+
item.append(el(doc, 'span', { className: 'bb-ws-combobox-option-label', text: option.label }));
|
|
502
|
+
if (option.hint) item.append(el(doc, 'span', { className: 'bb-ws-combobox-option-hint', text: option.hint }));
|
|
503
|
+
item.addEventListener('pointerdown', event => event.preventDefault?.());
|
|
504
|
+
item.addEventListener('click', () => choose(option));
|
|
505
|
+
list.append(item);
|
|
506
|
+
});
|
|
507
|
+
const active = list.children?.[state.active];
|
|
508
|
+
activeDescendant(active);
|
|
509
|
+
active?.scrollIntoView?.({ block: 'nearest' });
|
|
510
|
+
const rest = matches.length - state.shown.length;
|
|
511
|
+
status.textContent = !matches.length ? emptyText : rest > 0 ? `${matches.length}件中${state.shown.length}件を表示。ほか${rest}件は絞り込むと出ます` : `${matches.length}件`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function move(index) {
|
|
515
|
+
if (!state.shown.length) return;
|
|
516
|
+
state.active = Math.min(state.shown.length - 1, Math.max(0, index));
|
|
517
|
+
renderList();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function removeListeners() { doc.removeEventListener?.('pointerdown', outside); }
|
|
521
|
+
function outside(event) { if (!root.contains?.(event.target)) close(false); }
|
|
522
|
+
|
|
523
|
+
function open(query = '') {
|
|
524
|
+
if (state.disabled) return;
|
|
525
|
+
if (!state.open) { state.open = true; doc.addEventListener?.('pointerdown', outside); }
|
|
526
|
+
state.query = query; search.value = query; search.hidden = !usesSearch();
|
|
527
|
+
const selectedIndex = state.options.findIndex(option => option.value === state.value);
|
|
528
|
+
state.active = query ? 0 : Math.max(0, selectedIndex);
|
|
529
|
+
popup.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
|
530
|
+
renderList();
|
|
531
|
+
focusTarget().focus?.();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function close(returnFocus = true) {
|
|
535
|
+
if (!state.open) return;
|
|
536
|
+
state.open = false; removeListeners();
|
|
537
|
+
popup.hidden = true; trigger.setAttribute('aria-expanded', 'false'); activeDescendant(null);
|
|
538
|
+
if (returnFocus) trigger.focus?.();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function choose(option) {
|
|
542
|
+
const changed = option.value !== state.value;
|
|
543
|
+
state.value = option.value; renderTrigger(); close(true);
|
|
544
|
+
if (changed) onChange?.(option.value, option);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function keydown(event) {
|
|
548
|
+
const page = 10;
|
|
549
|
+
const keys = {
|
|
550
|
+
ArrowDown: () => move(state.active + 1), ArrowUp: () => move(state.active - 1),
|
|
551
|
+
PageDown: () => move(state.active + page), PageUp: () => move(state.active - page),
|
|
552
|
+
Enter: () => { const option = state.shown[state.active]; if (option) choose(option); },
|
|
553
|
+
Escape: () => close(true),
|
|
554
|
+
};
|
|
555
|
+
// Home and End move the caret while a search box is shown.
|
|
556
|
+
if (!usesSearch()) Object.assign(keys, { Home: () => move(0), End: () => move(state.shown.length - 1) });
|
|
557
|
+
if (event.key === 'Tab') { close(false); return; }
|
|
558
|
+
const action = keys[event.key];
|
|
559
|
+
if (!action) return;
|
|
560
|
+
event.preventDefault?.(); event.stopPropagation?.();
|
|
561
|
+
action();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
trigger.addEventListener('click', () => (state.open ? close(true) : open()));
|
|
565
|
+
trigger.addEventListener('keydown', event => {
|
|
566
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault?.(); open(); return; }
|
|
567
|
+
const printable = typeof event.key === 'string' && event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
568
|
+
if (printable && usesSearch()) { event.preventDefault?.(); open(event.key); }
|
|
569
|
+
});
|
|
570
|
+
search.addEventListener('input', () => { state.query = search.value; state.active = 0; renderList(); });
|
|
571
|
+
search.addEventListener('keydown', keydown);
|
|
572
|
+
list.addEventListener('keydown', keydown);
|
|
573
|
+
|
|
574
|
+
setOptions(options); renderTrigger();
|
|
575
|
+
return {
|
|
576
|
+
root, trigger, popup, search, list,
|
|
577
|
+
get value() { return state.value; },
|
|
578
|
+
get isOpen() { return state.open; },
|
|
579
|
+
open, close,
|
|
580
|
+
update({ options: nextOptions, value: nextValue, disabled } = {}) {
|
|
581
|
+
if (nextOptions !== undefined) setOptions(nextOptions);
|
|
582
|
+
if (nextValue !== undefined) state.value = String(nextValue ?? '');
|
|
583
|
+
if (disabled !== undefined) { state.disabled = Boolean(disabled); if (state.disabled) close(false); }
|
|
584
|
+
renderTrigger();
|
|
585
|
+
if (state.open) { search.hidden = !usesSearch(); renderList(); }
|
|
586
|
+
},
|
|
587
|
+
destroy() { close(false); removeListeners(); },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
@@ -23,7 +23,8 @@ export function workCanvasNotes(work) {
|
|
|
23
23
|
const summary = work.summary;
|
|
24
24
|
if (summary?.state === 'complete' || summary?.state === 'partial') {
|
|
25
25
|
const needs = summary.needs_check?.length;
|
|
26
|
-
|
|
26
|
+
// `opens: 'city'`: the host lets the quest open the city details, where these records are listed.
|
|
27
|
+
if (needs > 0) notes.push({ label: '記録の確認', tone: 'attention', opens: 'city', summary: `記録の要確認 ${needs}件`, text: `読めた未完了の仕事のうち${needs}件に、記録から把握できていないことがあります。仕事が止まっているという意味ではありません。詳細の絞り込みから根拠を確認できます。` });
|
|
27
28
|
}
|
|
28
29
|
const at = work.reads?.tasks?.read_at;
|
|
29
30
|
notes.push({ label: '区画の時点', tone: 'info', text: `仕事の読み込み:${shortTime(at) ?? '時点不明'}。表示は読み込んだ時点の記録です。` });
|
|
@@ -170,15 +170,60 @@
|
|
|
170
170
|
font-size: 11px;
|
|
171
171
|
font-weight: 700;
|
|
172
172
|
}
|
|
173
|
-
.bb-world-canvas-ui .bb-world-canvas-quest-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
174
|
-
/* A quest with a way down (
|
|
175
|
-
|
|
173
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
174
|
+
/* A quest with a way down (候補の確認, 区画の入口) is pressable as a whole row even though the quest list lets
|
|
175
|
+
the map take the pointer: its one link or button stretches over the row. */
|
|
176
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way {
|
|
177
|
+
position: relative;
|
|
178
|
+
pointer-events: auto;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
transition: background-color 120ms ease, border-color 120ms ease;
|
|
181
|
+
}
|
|
182
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way:hover,
|
|
183
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way:focus-within {
|
|
184
|
+
border-color: rgb(155 232 145 / 55%);
|
|
185
|
+
background: linear-gradient(110deg, rgb(20 44 62 / 94%), rgb(20 44 62 / 84%));
|
|
186
|
+
}
|
|
187
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a,
|
|
188
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action {
|
|
176
189
|
flex: none;
|
|
190
|
+
margin: 0;
|
|
191
|
+
padding: 0;
|
|
192
|
+
border: 0;
|
|
193
|
+
background: none;
|
|
177
194
|
color: #b5f2ab;
|
|
195
|
+
font: inherit;
|
|
178
196
|
text-decoration: underline;
|
|
179
197
|
text-underline-offset: 2px;
|
|
198
|
+
cursor: pointer;
|
|
180
199
|
pointer-events: auto;
|
|
181
200
|
}
|
|
201
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a::after,
|
|
202
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action::after { content: " ›"; }
|
|
203
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a::before,
|
|
204
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action::before { content: ""; position: absolute; inset: 0; border-radius: 4px; }
|
|
205
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible,
|
|
206
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible { outline: none; }
|
|
207
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible::before,
|
|
208
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible::before { outline: 2px solid #9be891; outline-offset: 1px; }
|
|
209
|
+
/* A quest with nowhere to go: flat and dashed, so it reads as a sign, not a button, with its reason. */
|
|
210
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.is-static {
|
|
211
|
+
border-style: dashed;
|
|
212
|
+
border-left-style: solid;
|
|
213
|
+
background: rgb(13 25 46 / 58%);
|
|
214
|
+
box-shadow: none;
|
|
215
|
+
}
|
|
216
|
+
/* On a narrow canvas the reason gives way before the row's own words. */
|
|
217
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.is-static .bb-world-canvas-quest-text { flex: none; }
|
|
218
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-reason {
|
|
219
|
+
flex: 0 1 auto;
|
|
220
|
+
min-width: 0;
|
|
221
|
+
overflow: hidden;
|
|
222
|
+
color: rgb(255 255 255 / 68%);
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
text-overflow: ellipsis;
|
|
226
|
+
}
|
|
182
227
|
|
|
183
228
|
.bb-world-canvas-ui .bb-world-canvas-edge {
|
|
184
229
|
position: absolute;
|
|
@@ -34,6 +34,8 @@ const nodeList = (nodes) => (Array.isArray(nodes) ? nodes : [nodes]).filter((nod
|
|
|
34
34
|
* focus unless its optional second argument is { focus: true }.
|
|
35
35
|
* addNote shows a quest only for an explicit nonempty summary; its full text
|
|
36
36
|
* remains in help. No summary is inferred from a long or nonurgent notice.
|
|
37
|
+
* A quest with a `link` or an `action` is pressable as a whole row; one without
|
|
38
|
+
* is drawn flat and may say its `reason` (why nothing opens from it).
|
|
37
39
|
*/
|
|
38
40
|
export function createWorldCanvasUI({
|
|
39
41
|
doc,
|
|
@@ -385,15 +387,17 @@ export function createWorldCanvasUI({
|
|
|
385
387
|
activate('help', help);
|
|
386
388
|
focus(helpTitle);
|
|
387
389
|
}
|
|
388
|
-
function addNote({ label = '', text = '', tone = '', summary, link = null } = {}) {
|
|
390
|
+
function addNote({ label = '', text = '', tone = '', summary, link = null, action = null, reason = null } = {}) {
|
|
389
391
|
if (disposed) return;
|
|
390
392
|
const note = element('section', 'bb-world-canvas-note');
|
|
391
393
|
if (label) note.append(element('h3', '', label));
|
|
392
394
|
if (text || summary) note.append(element('p', '', text || summary));
|
|
393
395
|
if (label || text || summary) notes.append(note);
|
|
394
396
|
if (typeof summary !== 'string' || !summary.trim()) return;
|
|
395
|
-
// A link
|
|
396
|
-
|
|
397
|
+
// A quest is a way down (a link already checked by the caller, or an action in this page) and then the
|
|
398
|
+
// whole row is pressable; without one it is drawn flat, so it never looks pressable (story-world-clickable-honesty-v1).
|
|
399
|
+
const way = link?.href ? 'link' : typeof action?.onClick === 'function' ? 'action' : null;
|
|
400
|
+
const quest = element('p', `bb-world-canvas-quest ${way ? 'has-way' : 'is-static'}`);
|
|
397
401
|
const icon = tone === 'attention' || tone === 'warning' ? '!' : 'i';
|
|
398
402
|
quest.append(
|
|
399
403
|
element('span', 'bb-world-canvas-quest-symbol', icon, { 'aria-hidden': 'true' }),
|
|
@@ -402,7 +406,16 @@ export function createWorldCanvasUI({
|
|
|
402
406
|
// The accessible name contains the unabridged summary even when visual text
|
|
403
407
|
// is ellipsized on a narrow canvas; the long explanation remains in help.
|
|
404
408
|
quest.setAttribute('aria-label', summary.trim());
|
|
405
|
-
if (
|
|
409
|
+
if (way === 'link') {
|
|
410
|
+
quest.append(element('a', link.className ?? '', link.label ?? summary.trim(), { href: link.href }));
|
|
411
|
+
} else if (way === 'action') {
|
|
412
|
+
const press = element('button', `bb-world-canvas-quest-action ${action.className ?? ''}`.trim(), action.label ?? summary.trim(), { type: 'button' });
|
|
413
|
+
listen(press, 'click', () => action.onClick());
|
|
414
|
+
quest.append(press);
|
|
415
|
+
} else if (typeof reason === 'string' && reason.trim()) {
|
|
416
|
+
// Why nothing opens from here (未接続 and the like), said in the row instead of only in help.
|
|
417
|
+
quest.append(element('span', 'bb-world-canvas-quest-reason', reason.trim()));
|
|
418
|
+
}
|
|
406
419
|
quests.append(quest);
|
|
407
420
|
}
|
|
408
421
|
function clearNotes() {
|
|
@@ -98,8 +98,11 @@ const STATION_YARD = Object.freeze({ xFrom: 6, xTo: 21.5, zFrom: 1.5, zTo: 12.5
|
|
|
98
98
|
* `exitLegend` (lines as DISTRICT_LEGEND) turns on the station yard for the tools of a business:
|
|
99
99
|
* stations come from `setExits`, and a picked station calls `onPickExit(id)`. Without it nothing of
|
|
100
100
|
* the yard is drawn or listed.
|
|
101
|
+
* `onPickStreet({ label, count })` makes each street's sign pressable (label null for the unlabelled work by
|
|
102
|
+
* the gate), and `onPickHall()` the hall, the plaza in front of it and the hall's name; without them they
|
|
103
|
+
* are scenery, as before (story-world-clickable-honesty-v1).
|
|
101
104
|
*/
|
|
102
|
-
export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection, exitLegend = null, onPickExit }) {
|
|
105
|
+
export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection, exitLegend = null, onPickExit, onPickStreet, onPickHall }) {
|
|
103
106
|
const stationsEnabled = Array.isArray(exitLegend);
|
|
104
107
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
105
108
|
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
|
|
@@ -349,6 +352,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
349
352
|
// The tools of the business (from the host), and their stations once the district is built.
|
|
350
353
|
let exitsData = [];
|
|
351
354
|
let stations = new Map();
|
|
355
|
+
/** The hall and its plaza, pressable when the host listens (`onPickHall`). */
|
|
356
|
+
let civic = [];
|
|
352
357
|
let stationLayer = null;
|
|
353
358
|
let stationLabels = [];
|
|
354
359
|
let gateAt = 0;
|
|
@@ -380,6 +385,17 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
380
385
|
return label;
|
|
381
386
|
}
|
|
382
387
|
|
|
388
|
+
/** A label that is a way in: pointer, a title saying where it goes, and a press. */
|
|
389
|
+
function enterable(label, title, onPress, enter) {
|
|
390
|
+
label.node.classList.add('is-enterable');
|
|
391
|
+
label.node.title = title;
|
|
392
|
+
label.node.setAttribute('data-enter', enter);
|
|
393
|
+
label.node.addEventListener('click', (event) => {
|
|
394
|
+
event.stopPropagation?.();
|
|
395
|
+
onPress();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
383
399
|
// --- people (records: worker, person named in the text; atmosphere: walkers) ---------------------
|
|
384
400
|
function person(clothes, { helmet = false } = {}) {
|
|
385
401
|
const group = new THREE.Group();
|
|
@@ -613,6 +629,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
613
629
|
labels = [];
|
|
614
630
|
lots = new Map();
|
|
615
631
|
stations = new Map();
|
|
632
|
+
civic = [];
|
|
616
633
|
stationLayer = null;
|
|
617
634
|
stationLabels = [];
|
|
618
635
|
walkers = [];
|
|
@@ -705,7 +722,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
705
722
|
root.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.5, 8), material(0x3f4844, { roughness: 0.6, metalness: 0.4 }), { x: -DISTRICT_STREETS.main - 0.5, y: 1.25, z: signZ }));
|
|
706
723
|
root.add(mesh(new THREE.BoxGeometry(1.1, 0.34, 0.05), material(block.label ? 0x2f6e4f : 0xbfb8a8, { roughness: 0.6 }), { x: -DISTRICT_STREETS.main - 0.5, y: 2.2, z: signZ }));
|
|
707
724
|
const text = block.label ? `${block.label}(${block.count}件)` : `未分類の空き地:何のための仕事か未記録(${block.count}件)`;
|
|
708
|
-
addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
|
|
725
|
+
const sign = addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
|
|
726
|
+
// The count is a way down: pressing the sign lists the work of this street (AC-05).
|
|
727
|
+
if (onPickStreet) enterable(sign, `${block.label ?? '何のための仕事か未記録の空き地'}の仕事${block.count}件を見る`, () => onPickStreet({ label: block.label, count: block.count }), '仕事を見る');
|
|
709
728
|
});
|
|
710
729
|
// Trees along the main street and lamps between them; a belt of trees round the district (scenery).
|
|
711
730
|
const trees = [];
|
|
@@ -754,10 +773,17 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
754
773
|
root.add(box(0.4, 0.9, 0.4, 0x9c7a62, { x: 2.8, y: 4.3, z: hallZ - 1.2 }));
|
|
755
774
|
const hallChimney = new THREE.Vector3(2.8, 5.2, hallZ - 1.2);
|
|
756
775
|
const purposeText = work?.status === 'ok' ? (work.purpose.text ?? '目的は未登録') : '目的を読めません';
|
|
757
|
-
addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
|
|
776
|
+
const hallLabel = addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
|
|
758
777
|
const plaza = mesh(new THREE.CircleGeometry(plazaRadius - 0.25, 48), material(0xf1ece1, { roughness: 1 }), { y: 0.24, z: plazaZ, shadow: false });
|
|
759
778
|
plaza.rotation.x = -Math.PI / 2;
|
|
760
779
|
root.add(plaza);
|
|
780
|
+
if (onPickHall) {
|
|
781
|
+
// The hall, its plaza and its name open what this business is for and its policies (AC-06).
|
|
782
|
+
hall.userData = { kind: 'hall' };
|
|
783
|
+
plaza.userData = { kind: 'hall' };
|
|
784
|
+
civic = [hall, plaza];
|
|
785
|
+
enterable(hallLabel, `${business.name}の目的と方針を見る`, () => onPickHall(), '目的と方針を見る');
|
|
786
|
+
}
|
|
761
787
|
const ring = mesh(new THREE.RingGeometry(plazaRadius - 0.25, plazaRadius, 48), material(0xcfc8b8, { roughness: 1 }), { y: 0.25, z: plazaZ, shadow: false });
|
|
762
788
|
ring.rotation.x = -Math.PI / 2;
|
|
763
789
|
root.add(ring);
|
|
@@ -897,7 +923,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
897
923
|
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
898
924
|
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
899
925
|
raycaster.setFromCamera(pointer, camera);
|
|
900
|
-
const targets = [...lots.values(), ...stations.values()];
|
|
926
|
+
const targets = [...lots.values(), ...stations.values(), ...civic];
|
|
901
927
|
for (const hit of raycaster.intersectObjects(targets, true)) {
|
|
902
928
|
let object = hit.object;
|
|
903
929
|
while (object && !targets.includes(object)) object = object.parent;
|
|
@@ -915,7 +941,10 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
915
941
|
renderer.domElement.addEventListener('pointerup', (event) => {
|
|
916
942
|
if (!downAt || Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y) > 5) return;
|
|
917
943
|
const group = pickAt(event.clientX, event.clientY);
|
|
918
|
-
if (group?.userData.kind === '
|
|
944
|
+
if (group?.userData.kind === 'hall') {
|
|
945
|
+
select(null);
|
|
946
|
+
onPickHall?.();
|
|
947
|
+
} else if (group?.userData.kind === 'exit') {
|
|
919
948
|
// A station selects its tool in the city details; it never leaves the world.
|
|
920
949
|
select(group, { focus: false });
|
|
921
950
|
onPickExit?.(group.userData.exit.id);
|
package/ui/world/world-view.css
CHANGED
|
@@ -159,6 +159,12 @@
|
|
|
159
159
|
color: #ffffff;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/* Other names that are ways in (the plaza, a street's sign, the hall) say where they go (story-world-clickable-honesty-v1). */
|
|
163
|
+
.bb-world-label.is-enterable[data-enter]:hover::after,
|
|
164
|
+
.bb-world-label.is-enterable[data-enter].is-hover::after {
|
|
165
|
+
content: ' ' attr(data-enter) ' ›';
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
/* With nothing chosen, the rail lists the ways in (AC-19). */
|
|
163
169
|
.bb-world-entrances {
|
|
164
170
|
list-style: none;
|