@unson/brainbase-mcp 0.24.1 → 0.26.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.
@@ -563,3 +563,82 @@
563
563
  .bb-world-district-notice[hidden] {
564
564
  display: none;
565
565
  }
566
+
567
+ /* --- the tools a business uses (story-world-business-exits-v1) ----------- */
568
+ .bb-world-exit-list {
569
+ list-style: none;
570
+ padding-left: 0;
571
+ }
572
+
573
+ .bb-world-exit {
574
+ padding: 6px 8px;
575
+ border: 1px solid var(--bb-color-border);
576
+ border-left: 3px solid var(--bb-color-border);
577
+ border-radius: var(--bb-radius-sm);
578
+ }
579
+
580
+ .bb-world-exit.is-available { border-left-color: #c99a3a; }
581
+ .bb-world-exit.is-restricted { border-left-color: #c8323c; }
582
+ .bb-world-exit.is-unknown,
583
+ .bb-world-exit.is-unavailable { border-left-color: #9aa3a8; }
584
+
585
+ .bb-world-exit.is-selected {
586
+ border-color: var(--bb-color-accent);
587
+ box-shadow: 0 0 0 2px rgb(8 125 98 / 18%);
588
+ }
589
+
590
+ .bb-world-exit-head {
591
+ display: flex;
592
+ flex-wrap: wrap;
593
+ align-items: baseline;
594
+ gap: 6px;
595
+ }
596
+
597
+ .bb-world-exit-label {
598
+ font-weight: 600;
599
+ overflow-wrap: anywhere;
600
+ }
601
+
602
+ .bb-world-exit-state {
603
+ font-size: 11px;
604
+ padding: 0 6px;
605
+ border-radius: 999px;
606
+ border: 1px solid var(--bb-color-border);
607
+ color: var(--bb-color-muted);
608
+ }
609
+
610
+ .bb-world-exit-state.is-available { color: #7a5a12; border-color: #e0c27a; }
611
+ .bb-world-exit-state.is-restricted { color: #a1192a; border-color: #e3a1a7; }
612
+
613
+ .bb-world-exit-attention {
614
+ display: block;
615
+ margin-top: 2px;
616
+ font-size: 12px;
617
+ color: var(--bb-color-ink);
618
+ }
619
+
620
+ .bb-world-exit-links {
621
+ display: flex;
622
+ flex-wrap: wrap;
623
+ gap: 10px;
624
+ }
625
+
626
+ .bb-world-exits-unreadable {
627
+ display: block;
628
+ color: var(--bb-color-warning);
629
+ font-weight: 600;
630
+ }
631
+
632
+ .bb-world-fallback-exits {
633
+ margin: 4px 0 10px;
634
+ }
635
+
636
+ .bb-world-label.is-exit {
637
+ font-size: 11px;
638
+ font-weight: 600;
639
+ }
640
+
641
+ .bb-world-label.is-exit.is-available { border-color: #e0c27a; }
642
+ .bb-world-label.is-exit.is-restricted { border-color: #e3a1a7; color: #a1192a; }
643
+ .bb-world-label.is-exit.is-unknown,
644
+ .bb-world-label.is-exit.is-unavailable { color: var(--bb-color-muted); }
@@ -20,6 +20,10 @@
20
20
  * review date passed); a dashed ring says the text names a person the assignee
21
21
  * field does not. Above the cities, a sign counts the open work with such a
22
22
  * gap, and a grey sign says the work could not be read (never zero).
23
+ *
24
+ * A host may also give the tools a business uses (`businessExits`, story-world-business-exits-v1):
25
+ * they are listed in the city details and stand as stations outside the district's gate. The world
26
+ * only links out to them; it never changes them.
23
27
  */
24
28
 
25
29
  import { THREE, MapControls } from './world-vendor.js';
@@ -29,6 +33,7 @@ import { createWorldCanvasUI } from './world-canvas-ui.js';
29
33
  import { workCanvasNotes } from './world-canvas-notes.js';
30
34
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
31
35
  import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
36
+ import { exitLegend, exitsRailBlock, normalizeBusinessExits } from './world-exits.js';
32
37
  import {
33
38
  makeWorkspaceElement as el,
34
39
  workspacePageHeader,
@@ -160,12 +165,19 @@ function makeLabel(doc, text, className) {
160
165
  return label;
161
166
  }
162
167
 
163
- function webglAvailable(doc) {
168
+ /**
169
+ * 'ok' when WebGL runs on a GPU, 'slow' when the browser would only draw it in software (building the
170
+ * scene then freezes the page for tens of seconds), 'none' when there is no WebGL at all.
171
+ */
172
+ function webglSupport(doc) {
164
173
  try {
174
+ const strict = { failIfMajorPerformanceCaveat: true };
165
175
  const canvas = doc.createElement('canvas');
166
- return Boolean(canvas.getContext('webgl2') || canvas.getContext('webgl'));
176
+ if (canvas.getContext('webgl2', strict) || canvas.getContext('webgl', strict)) return 'ok';
177
+ const fallback = doc.createElement('canvas');
178
+ return fallback.getContext('webgl2') || fallback.getContext('webgl') ? 'slow' : 'none';
167
179
  } catch {
168
- return false;
180
+ return 'none';
169
181
  }
170
182
  }
171
183
 
@@ -1380,12 +1392,15 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1380
1392
  };
1381
1393
  }
1382
1394
 
1383
- function renderFallbackList(doc, root, businesses, rows) {
1395
+ /** `exitSlot(business)` gives the node to show under a business for its tools, when the host gives them. */
1396
+ function renderFallbackList(doc, root, businesses, rows, exitSlot = null) {
1384
1397
  const list = el(doc, 'div', { className: 'bb-world-fallback' });
1385
1398
  list.append(el(doc, 'h2', { text: '事業' }));
1386
1399
  const ul = el(doc, 'ul');
1387
1400
  for (const business of businesses) {
1388
- ul.append(el(doc, 'li', { text: `${business.name}(${kindEntry(business.kind).label}・案件${business.engagements.length}件)` }));
1401
+ const item = el(doc, 'li', { text: `${business.name}(${kindEntry(business.kind).label}・案件${business.engagements.length}件)` });
1402
+ if (exitSlot) item.append(exitSlot(business));
1403
+ ul.append(item);
1389
1404
  }
1390
1405
  list.append(ul, el(doc, 'h2', { text: '判断の種類' }));
1391
1406
  const ol = el(doc, 'ul');
@@ -1437,8 +1452,16 @@ function addressSelection() {
1437
1452
  * host's task screen (to check or correct it). Without one the rail says it cannot be corrected here.
1438
1453
  * @param {'standard' | 'canvas'} [options.presentation] Canvas owns its accessible overlays and ignores the host rail.
1439
1454
  * @param {{ read(): { business: string | null, site: string | null }, write(selection): void }} [options.selection]
1455
+ * @param {(business: object) => (BusinessExitsAnswer | Promise<BusinessExitsAnswer>)} [options.businessExits] The tools
1456
+ * a business uses (story-world-business-exits-v1). The answer is
1457
+ * `{ status: 'complete'|'partial'|'failed'|'not_connected', read_at?, reason?, exits: [{ id, label, href,
1458
+ * state: 'available'|'restricted'|'unknown'|'unavailable', note?, action?: { label, href }, attention?: { count?,
1459
+ * label?, as_of? } }] }`. `href` and `action.href` must be `https:` or a link inside the host (`/…`, `?…`);
1460
+ * others are dropped and counted. Without it the world is drawn exactly as before.
1461
+ * @typedef {{ status: string, read_at?: string, reason?: string, exits: object[] }} BusinessExitsAnswer
1440
1462
  */
1441
- export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection() }) {
1463
+ export function createWorldView({ root, rail, page, presentation = 'standard', document: explicitDocument, fetcher, projectHref = (project) => `#projects?project=${encodeURIComponent(project.id)}`, taskHref = null, selection = addressSelection(), businessExits = null }) {
1464
+ const exitsEnabled = typeof businessExits === 'function';
1442
1465
  const doc = explicitDocument ?? globalThis.document;
1443
1466
  const reducedMotion = Boolean(globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
1444
1467
  const canvasMode = presentation === 'canvas';
@@ -1503,6 +1526,10 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1503
1526
  el(doc, 'span', { className: 'bb-world-chip is-ring', text: '破線の輪=本文に人物(担当欄は未接続)' }),
1504
1527
  el(doc, 'span', { className: 'bb-world-chip is-road', text: '実線=記録された関係・破線=推定' }),
1505
1528
  );
1529
+ // The stations' legend, in the owner's words once the vocabulary is read (AC-08).
1530
+ const exitLegendList = el(doc, 'ul', { className: 'bb-world-exit-legend', attrs: { 'aria-label': '駅の見方' } });
1531
+ const renderExitLegend = () => exitLegendList.replaceChildren(...exitLegend(worldVocabulary).map(([className, text]) => el(doc, 'li', { className, text })));
1532
+ if (exitsEnabled) renderExitLegend();
1506
1533
  const stage = el(doc, 'div', { className: 'bb-world-stage' });
1507
1534
  const labelsLayer = el(doc, 'div', { className: 'bb-world-labels' });
1508
1535
  stage.append(labelsLayer);
@@ -1530,6 +1557,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1530
1557
  const list = el(doc, 'ul');
1531
1558
  for (const [, text] of DISTRICT_LEGEND) list.append(el(doc, 'li', { text }));
1532
1559
  help.append(list);
1560
+ if (exitsEnabled) help.append(exitLegendList);
1533
1561
  if (page?.source) help.append(el(doc, 'p', { text: page.source }));
1534
1562
  canvasUI.setHelp([help]);
1535
1563
  } else {
@@ -1656,7 +1684,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1656
1684
  let workConnected = null;
1657
1685
  const workCache = new Map();
1658
1686
  const workRequests = new Map();
1659
- const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null, detachedSiteId: null };
1687
+ const current = { code: null, siteId: null, level: 'world', focus: null, pendingSite: null, detachedSiteId: null, exitId: null };
1660
1688
 
1661
1689
  /** Inside a city the notices fold away (they would cover it); its rail carries the sources and times. */
1662
1690
  function setLevel(level) {
@@ -1677,6 +1705,58 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1677
1705
  return knownBusinesses.find((business) => business.code === code) ?? null;
1678
1706
  }
1679
1707
 
1708
+ // --- the tools a business uses (story-world-business-exits-v1) ----------------------------------
1709
+ /** Normalized answers by business code; a read in flight is kept so it is asked only once. */
1710
+ const exitsCache = new Map();
1711
+ const exitsRequests = new Map();
1712
+ /** Under each business in the list shown without 3D (AC-09). */
1713
+ const fallbackExitSlots = new Map();
1714
+ /** The stations of a business for the district: its read tools, or none until read. */
1715
+ const stationsFor = (code) => (exitsEnabled ? exitsCache.get(code)?.exits ?? [] : undefined);
1716
+
1717
+ function fallbackExitSlot(business) {
1718
+ const slot = el(doc, 'div', { className: 'bb-world-fallback-exits' });
1719
+ fallbackExitSlots.set(business.code, slot);
1720
+ slot.append(exitsRailBlock(doc, { result: exitsCache.get(business.code) ?? null, vocabulary: worldVocabulary }).block);
1721
+ return slot;
1722
+ }
1723
+
1724
+ function loadExits(business) {
1725
+ if (!exitsEnabled || disposed) return;
1726
+ const { code } = business;
1727
+ if (exitsCache.has(code) || exitsRequests.has(code)) return;
1728
+ const request = (async () => {
1729
+ try {
1730
+ return normalizeBusinessExits(await businessExits(business));
1731
+ } catch (error) {
1732
+ return { ...normalizeBusinessExits({ status: 'failed', exits: [] }), reason: error instanceof Error ? error.message : 'request_failed' };
1733
+ }
1734
+ })();
1735
+ exitsRequests.set(code, request);
1736
+ void request.then((result) => {
1737
+ if (disposed || exitsRequests.get(code) !== request) return;
1738
+ exitsRequests.delete(code);
1739
+ exitsCache.set(code, result);
1740
+ fallbackExitSlots.get(code)?.replaceChildren(exitsRailBlock(doc, { result, vocabulary: worldVocabulary }).block);
1741
+ // AC-10: an answer for a city that is no longer open does not reopen it.
1742
+ if (current.code !== code) return;
1743
+ if (districtCode === code && district?.isOpen()) district.setExits(result.exits);
1744
+ if (canvasMode || current.level !== 'site') renderCityRail(business);
1745
+ });
1746
+ }
1747
+
1748
+ /** A picked station: the city details open with that tool's row selected; nothing leaves the world. */
1749
+ function selectExit(business, exitId) {
1750
+ current.exitId = exitId;
1751
+ if (current.level === 'site') {
1752
+ canvasUI?.closeTask();
1753
+ setCurrent(business.code, null);
1754
+ setLevel('city');
1755
+ }
1756
+ renderCityRail(business, { revealExit: true });
1757
+ canvasUI?.openRail();
1758
+ }
1759
+
1680
1760
  /** The registration, engagements, link out and judgments of a city (as before the work layer). */
1681
1761
  function cityRegistrationBlocks(business) {
1682
1762
  const blocks = [];
@@ -1730,7 +1810,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1730
1810
  }
1731
1811
  }
1732
1812
 
1733
- function renderCityRail(business, { revealFocus = false } = {}) {
1813
+ function renderCityRail(business, { revealFocus = false, revealExit = false } = {}) {
1734
1814
  const blocks = [workspaceRailHead(doc, { kicker: `事業・${kindEntry(business.kind).label}`, title: business.name, lead: business.purpose ?? undefined })];
1735
1815
  let focusBlock = null;
1736
1816
  const work = workCache.get(business.code);
@@ -1762,8 +1842,15 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1762
1842
  blocks.push(directory);
1763
1843
  }
1764
1844
  blocks.push(...cityRegistrationBlocks(business));
1845
+ let exitRow = null;
1846
+ if (exitsEnabled) {
1847
+ const tools = exitsRailBlock(doc, { result: exitsCache.get(business.code) ?? null, vocabulary: worldVocabulary, selectedId: current.exitId });
1848
+ blocks.push(tools.block);
1849
+ exitRow = tools.selectedRow;
1850
+ }
1765
1851
  showRail(blocks);
1766
1852
  if (revealFocus) reveal(focusBlock ?? blocks[0]);
1853
+ if (revealExit && exitRow) reveal(exitRow);
1767
1854
  }
1768
1855
 
1769
1856
  /** The open tasks a focus picks: a kind of gap, or a recorded state; null for all. */
@@ -1831,7 +1918,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1831
1918
  updateDistrictNotes(work);
1832
1919
  scene?.showWork(code, work);
1833
1920
  if (districtCode === code && district?.isOpen()) {
1834
- district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1921
+ district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
1835
1922
  const growth = noteGrowth(code, work, previous);
1836
1923
  district.celebrate(growth?.changes ?? null);
1837
1924
  district.focus(focusIdsFor(work, current.focus));
@@ -1875,12 +1962,19 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1875
1962
  if (anchor.taskId === current.siteId && current.detachedSiteId !== current.siteId) canvasUI?.setTaskAnchor(anchor);
1876
1963
  else if (!anchor.visible && !current.siteId) canvasUI?.setTaskAnchor({ visible: false });
1877
1964
  },
1965
+ ...(exitsEnabled ? {
1966
+ exitLegend: exitLegend(worldVocabulary),
1967
+ onPickExit: (exitId) => {
1968
+ const owner = businessOf(current.code);
1969
+ if (owner) selectExit(owner, exitId);
1970
+ },
1971
+ } : {}),
1878
1972
  });
1879
1973
  district.setSky(SKY_MODES[skyIndex][0]);
1880
1974
  scene.setPaused(true);
1881
1975
  if (!canvasMode) { legend.hidden = true; workLegend.hidden = true; }
1882
1976
  if (districtCode !== business.code || !district.isOpen()) {
1883
- district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
1977
+ district.show(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
1884
1978
  districtCode = business.code;
1885
1979
  const growth = growthFor(business.code) ?? noteGrowth(business.code, work, readSnapshot(business.code));
1886
1980
  district.celebrate(growth?.changes ?? null);
@@ -1911,7 +2005,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1911
2005
  scene?.showWork(business.code, work);
1912
2006
  if (current.level !== 'world') {
1913
2007
  if (districtCode === business.code && district?.isOpen()) {
1914
- district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])) });
2008
+ district.refresh(business, work, { neighbours: new Map(knownBusinesses.map((entry) => [entry.code, entry.name])), exits: stationsFor(business.code) });
1915
2009
  district.focus(focusIdsFor(work, current.focus));
1916
2010
  } else enterDistrict(business, work);
1917
2011
  }
@@ -1931,6 +2025,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1931
2025
  current.pendingSite = null;
1932
2026
  if (current.code !== business.code) {
1933
2027
  current.focus = null;
2028
+ current.exitId = null;
1934
2029
  leaveDistrict();
1935
2030
  }
1936
2031
  setCurrent(business.code, null);
@@ -1949,6 +2044,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1949
2044
  }
1950
2045
  }
1951
2046
  renderCityRail(business);
2047
+ loadExits(business);
1952
2048
  if (!work) void loadWork(business);
1953
2049
  }
1954
2050
 
@@ -2020,6 +2116,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2020
2116
  setCurrent(null, null);
2021
2117
  setLevel('world');
2022
2118
  current.focus = null;
2119
+ current.exitId = null;
2023
2120
  showEmptyRail();
2024
2121
  }
2025
2122
 
@@ -2240,6 +2337,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2240
2337
  const businessPayload = businessesResult.ok ? businessesResult.data : null;
2241
2338
  const businesses = businessPayload?.status === 'ok' ? businessPayload.businesses : [];
2242
2339
  if (businessPayload?.status === 'ok' && businessPayload.vocabulary) worldVocabulary = businessPayload.vocabulary;
2340
+ if (exitsEnabled) renderExitLegend();
2243
2341
  if (!businessesResult.ok || businessPayload?.status !== 'ok') {
2244
2342
  const reason = businessesResult.ok ? `${BUSINESS_STATE_TEXT[businessPayload?.status] ?? '状態不明'}(${businessPayload?.reason ?? '理由不明'})` : `取得に失敗しました(${businessesResult.error})`;
2245
2343
  note('事業', `${reason}。事業の件数は未確認です。0件とは確認できません。`, 'warning', '事業:読めない(0件とは確認できません)');
@@ -2284,17 +2382,21 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2284
2382
 
2285
2383
  knownBusinesses = businesses;
2286
2384
  canvasUI?.setCities(businesses, current.code);
2287
- if (!webglAvailable(doc)) {
2288
- note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning', '3D表示を利用できません・一覧表示');
2385
+ const webgl = webglSupport(doc);
2386
+ if (webgl !== 'ok') {
2387
+ if (webgl === 'slow') note('表示', 'この環境ではGPUを使えず、3Dの描画が非常に遅いため、一覧で出しています。', 'warning', '3D表示が遅い環境・一覧表示');
2388
+ else note('表示', 'この環境では3Dを表示できないため、一覧で出しています。', 'warning', '3D表示を利用できません・一覧表示');
2389
+ const exitSlot = exitsEnabled ? fallbackExitSlot : null;
2289
2390
  if (canvasMode) {
2290
- renderFallbackList(doc, stage, businesses, rows);
2391
+ renderFallbackList(doc, stage, businesses, rows, exitSlot);
2291
2392
  showEmptyRail();
2292
2393
  restoreSelection();
2293
2394
  void loadWorkSummary(note);
2294
2395
  } else {
2295
2396
  stage.hidden = true;
2296
- renderFallbackList(doc, wrap, businesses, rows);
2397
+ renderFallbackList(doc, wrap, businesses, rows, exitSlot);
2297
2398
  }
2399
+ for (const business of exitsEnabled ? businesses : []) loadExits(business);
2298
2400
  return;
2299
2401
  }
2300
2402
  scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });