@unson/brainbase-mcp 0.41.4 → 0.41.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.41.4",
3
+ "version": "0.41.6",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -483,5 +483,5 @@
483
483
  "engines": {
484
484
  "node": ">=20"
485
485
  },
486
- "gitHead": "56d428ab1e2ba87b273a09119b2c5b4f25314440"
486
+ "gitHead": "0c67baf7d7c57f4f671c966c8053705af65ba4d0"
487
487
  }
@@ -62,6 +62,10 @@
62
62
  --bb-color-notice-control-border: #ffffff45;
63
63
  --bb-color-inline-error: #9b3d3d;
64
64
  --bb-color-record-missing: #80601c;
65
+ --bb-color-drawer-overlay: rgb(13 25 46 / 6%);
66
+ --bb-color-drawer-overlay-clear: rgb(13 25 46 / 0%);
67
+ --bb-shadow-drawer: -18px 0 48px rgb(13 25 46 / 10%);
68
+ --bb-shadow-popover: 0 8px 28px rgb(13 25 46 / 14%);
65
69
  --bb-color-dialog-overlay: #0d192e66;
66
70
  --bb-color-scrollbar-rgb: 36,50,75;
67
71
  --bb-color-scrollbar-dark-rgb: 203,212,225;
@@ -14,7 +14,7 @@ import {
14
14
  workspaceNotice,
15
15
  workspacePageHeader,
16
16
  workspaceSectionTitle,
17
- workspaceHelp, workspaceDialog, workspaceDisclosure, workspaceErrorCard,
17
+ workspaceHelp, workspaceDialog, workspaceDisclosure, workspaceErrorCard, workspaceLoadingCard, workspaceDismissNotification,
18
18
  } from './workspace-kit.js';
19
19
 
20
20
  export const FOUNDATION_OVERVIEW_CONTRACT_VERSION = 'foundation-overview.v1';
@@ -660,10 +660,25 @@ function renderModels(doc, catalog, viewState, callbacks, recordContexts) {
660
660
  return section;
661
661
  }
662
662
 
663
- function renderLoading(doc, state, callbacks) {
664
- const notice = workspaceNotice(doc, { label: '読み込み', text: '目的・哲学・世界モデルを読み込んでいます。' });
665
- notice.append(workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: callbacks.load }));
666
- return notice;
663
+ /** Keep one loading card across redraws so completion can leave visibly. */
664
+ function renderLoadingState(doc, root, viewState, loading, onLoading) {
665
+ if (onLoading) { onLoading(loading); return; }
666
+ if (loading) {
667
+ viewState.cancelLoadingDismiss?.();
668
+ viewState.cancelLoadingDismiss = null;
669
+ viewState.loadingCard ??= workspaceLoadingCard(doc);
670
+ }
671
+ const card = viewState.loadingCard;
672
+ if (!card) return;
673
+ const stack = viewState.loadingStack ??= makeElement(doc, 'aside', { className: 'bb-ws-error-stack', attrs: { 'aria-label': '読み込み状況' } });
674
+ stack.append(card); root.append(stack);
675
+ if (!loading && !viewState.cancelLoadingDismiss) {
676
+ const cancel = workspaceDismissNotification(card, () => {
677
+ if (viewState.loadingCard !== card) return;
678
+ viewState.loadingCard = null; viewState.cancelLoadingDismiss = null; viewState.loadingStack = null; stack.remove();
679
+ });
680
+ if (viewState.loadingCard === card) viewState.cancelLoadingDismiss = cancel;
681
+ }
667
682
  }
668
683
 
669
684
  function renderFailure(doc, state, callbacks) {
@@ -684,6 +699,7 @@ export function renderFoundationOverview(root, state, {
684
699
  recordContexts,
685
700
  partial = false,
686
701
  onNotice,
702
+ onLoading,
687
703
  } = {}) {
688
704
  if (!root) throw new TypeError('root is required');
689
705
  const doc = getDocument(explicitDocument);
@@ -705,14 +721,13 @@ export function renderFoundationOverview(root, state, {
705
721
  else surface.append(workspaceErrorCard(doc, notice));
706
722
  };
707
723
  if (state.status === 'loading') {
708
- surface.append(renderLoading(doc, state, callbacks)); root.append(surface); return surface;
724
+ root.append(surface); renderLoadingState(doc, root, viewState, true, onLoading); return surface;
709
725
  }
710
726
  if (state.status !== 'ready') {
711
727
  notify({ title: '取得失敗', message: `${state.error?.message ?? '目的・哲学・世界モデルを確認できません。'} 登録なし・0件として表示していません。`, onRetry: callbacks.load });
712
- root.append(surface); return surface;
728
+ root.append(surface); renderLoadingState(doc, root, viewState, false, onLoading); return surface;
713
729
  }
714
730
  const catalog = state.catalog;
715
- if (partial) notify({ title: '取得済みの範囲', tone: 'warning', message: '以下の登録なし・検索結果は取得できた記録だけが対象です。全体は未確認です。' });
716
731
  surface.append(renderObjectives(doc, catalog, viewState, callbacks, recordContexts));
717
732
  const fold = (title, help, content, initialOpen) => {
718
733
  const key = title === '哲学' ? 'philosophyOpen' : 'modelsOpen';
@@ -721,6 +736,7 @@ export function renderFoundationOverview(root, state, {
721
736
  surface.append(fold('哲学', '判断の前提となる考え方です。登録された本文と、判断への適用範囲を分けて表示します。', renderPhilosophies(doc, catalog, recordContexts), true));
722
737
  surface.append(fold('世界モデル', '何をすると結果が変わるか、という仮説。未検証・許可用途を分けて確認します。', renderModels(doc, catalog, viewState, callbacks, recordContexts), false));
723
738
  root.append(surface);
739
+ renderLoadingState(doc, root, viewState, false, onLoading);
724
740
  return surface;
725
741
  }
726
742
 
@@ -731,7 +747,7 @@ function appendRecordSource(doc, card, record, contexts) {
731
747
 
732
748
  /** Compose independently validated catalogs by topic, preserving each reference's source context. */
733
749
  export function renderFoundationCollectionOverview(root, sources, {
734
- document: explicitDocument, viewState = { query: '', page: 0 }, callbacks = {}, scopeLabel = '全体', onNotice,
750
+ document: explicitDocument, viewState = { query: '', page: 0 }, callbacks = {}, scopeLabel = '全体', onNotice, onLoading,
735
751
  } = {}) {
736
752
  const doc = getDocument(explicitDocument);
737
753
  root.replaceChildren();
@@ -742,7 +758,7 @@ export function renderFoundationCollectionOverview(root, sources, {
742
758
  for (const source of sources) {
743
759
  if (source.state.status !== 'ready') {
744
760
  if (source.state.status === 'loading') {
745
- root.append(workspaceNotice(doc, { label: source.label, text: '目的・哲学・世界モデルを読み込んでいます。' }));
761
+ // A single shared loading card is rendered after the available catalogs.
746
762
  } else {
747
763
  const notice = { title: source.label, message: `取得失敗: ${source.state.error?.message ?? 'この対象は未確認です。0件ではありません。'}`, onRetry: callbacks.load };
748
764
  if (onNotice) onNotice(notice); else root.append(workspaceErrorCard(doc, notice));
@@ -778,10 +794,11 @@ export function renderFoundationCollectionOverview(root, sources, {
778
794
  const mount = makeElement(doc, 'div');
779
795
  renderFoundationOverview(mount, { status: 'ready', catalog }, {
780
796
  document: doc, scopeId: scopeLabel, showHeader: false, viewState, callbacks,
781
- recordContexts: contexts, partial: ready !== sources.length, onNotice,
797
+ recordContexts: contexts, partial: ready !== sources.length, onNotice, onLoading: () => {},
782
798
  });
783
799
  root.append(mount);
784
800
  }
801
+ renderLoadingState(doc, root, viewState, sources.some(source => source.state.status === 'loading'), onLoading);
785
802
  return root;
786
803
  }
787
804
 
@@ -841,6 +858,8 @@ export function createFoundationOverview({ root, port, scopeId, document: explic
841
858
  destroyed = true;
842
859
  generation += 1;
843
860
  viewState.dialog?.close();
861
+ viewState.cancelLoadingDismiss?.();
862
+ viewState.loadingCard = null;
844
863
  root.replaceChildren();
845
864
  },
846
865
  };
@@ -583,3 +583,37 @@
583
583
  .bb-jh-ledger .bb-ws-ledger-row > span:nth-child(3)::before { content: '使った参照'; }
584
584
  .bb-jh-ledger .bb-ws-ledger-row > span:nth-child(4)::before { content: '入口・状態'; }
585
585
  }
586
+
587
+ /* Compact workspace header, search controls and shared floating notifications. */
588
+ .bb-jh { position: relative; }
589
+ .bb-jh > .bb-ws-page { margin-bottom: var(--bb-space-6, 32px); }
590
+ .bb-jh .bb-ws-page-head h1 { font-size: 24px; line-height: 1.4; font-weight: 600; letter-spacing: .02em; }
591
+ .bb-jh-controls { margin-bottom: var(--bb-space-4, 16px); }
592
+ .bb-jh-checked { font-size: 11px; font-variant-numeric: tabular-nums; }
593
+ .bb-jh-filter-fields { display: grid; gap: 16px; }
594
+ .bb-jh-filter-fields .bb-jh-periods { flex-wrap: wrap; gap: 6px; }
595
+ .bb-jh-filter-fields .bb-jh-normal-filters { display: grid; flex: none; gap: 16px; }
596
+ .bb-jh-filter-fields .bb-jh-filter { display: grid; gap: 6px; }
597
+ .bb-jh-filter-fields select { width: 100%; }
598
+ .bb-jh-refresh { display: grid; place-items: center; flex: none; width: 32px; height: 32px; padding: 6px; }
599
+ .bb-jh-refresh-mark { display: block; width: 16px; height: 16px; }
600
+ .bb-jh-notifications { display: grid; gap: 8px; }
601
+ .bb-jh-notifications .bb-jh-banner { margin: 0; }
602
+ .bb-jh-notifications .bb-jh-banner-sources { display: grid; gap: 4px; margin-top: 8px; }
603
+ .bb-jh-notifications .bb-jh-source-detail { margin: 8px 0 0; }
604
+ .bb-jh-notifications .bb-jh-source-detail summary { color: inherit; }
605
+ .bb-jh-notifications .bb-jh-source-detail-body { position: static; max-width: none; background: transparent; border-color: var(--bb-color-notice-border); color: inherit; }
606
+ .bb-jh-detail .bb-ws-rail-head { margin-top: 0; }
607
+ @media (max-width: 760px) {
608
+ .bb-jh-controls { flex-direction: row; flex-wrap: wrap; align-items: center; }
609
+ .bb-jh-search { flex: 1 1 150px; }
610
+ .bb-jh-toolbar-status { margin-left: auto; }
611
+ }
612
+ .bb-jh-notifications .bb-jh-banner {
613
+ border-color: var(--bb-color-notice-border);
614
+ background: var(--bb-color-notice-background);
615
+ color: var(--bb-color-notice-text);
616
+ border-left-color: var(--bb-color-notice-warning);
617
+ }
618
+ .bb-jh-notifications .bb-jh-banner.is-danger { border-left-color: var(--bb-color-notice-danger); }
619
+ .bb-jh-notifications .bb-jh-banner-sources > span { white-space: normal; }
@@ -11,6 +11,11 @@
11
11
  import {
12
12
  makeWorkspaceElement,
13
13
  workspaceButton,
14
+ workspaceDialog,
15
+ workspaceFilterPopover,
16
+ workspaceErrorCard,
17
+ workspaceLoadingCard,
18
+ workspaceDismissNotification,
14
19
  workspaceDefinition,
15
20
  workspaceDetailEmpty,
16
21
  workspaceLedger,
@@ -1278,14 +1283,16 @@ function sourceChips(doc, sources) {
1278
1283
  }
1279
1284
 
1280
1285
  function historyBanner(doc, { label, tone, role, sources, home, note, reload }) {
1281
- const banner = makeWorkspaceElement(doc, 'div', {
1282
- className: `bb-jh-banner is-${tone}`,
1283
- attrs: { role: role ?? 'status' },
1286
+ const banner = workspaceErrorCard(doc, {
1287
+ title: label,
1288
+ tone,
1289
+ message: note || '取得できた記録だけを表示しています。全体の取得状態は通知の詳細で確認できます。',
1284
1290
  });
1285
- banner.append(makeWorkspaceElement(doc, 'strong', { text: label }));
1286
- if (sources?.length) banner.append(sourceChips(doc, sources));
1287
- if (note) banner.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-banner-note', text: note }));
1288
- if (home) banner.append(sourceDetail(doc, home));
1291
+ banner.className += ` bb-jh-banner is-${tone}`;
1292
+ banner.setAttribute('role', role ?? 'status');
1293
+ const details = findFirst(banner, node => node.tagName === 'DETAILS');
1294
+ if (sources?.length) details.append(sourceChips(doc, sources));
1295
+ if (home) details.append(sourceDetail(doc, home));
1289
1296
  if (reload) banner.append(reload);
1290
1297
  return banner;
1291
1298
  }
@@ -1315,30 +1322,7 @@ function statusNotice(doc, phase, error, home, onRetry, { loading = false, slow
1315
1322
  });
1316
1323
  }
1317
1324
  if (loading) {
1318
- const progressHome = arrived ?? home;
1319
- const counts = new Map();
1320
- for (const record of progressHome?.records ?? []) {
1321
- const key = normalEntrypoint(record.entrypoint);
1322
- counts.set(key, (counts.get(key) ?? 0) + 1);
1323
- }
1324
- const known = Array.isArray(progressHome?.coverage?.sources) && progressHome.coverage.sources.length
1325
- ? progressHome.coverage.sources
1326
- : NORMAL_ENTRYPOINTS.filter((entrypoint) => entrypoint !== 'unknown').map((entrypoint) => ({ entrypoint, status: 'pending', reason: null }));
1327
- const sources = known.map((source) => {
1328
- const count = counts.get(normalEntrypoint(source.entrypoint)) ?? 0;
1329
- if (count > 0) return { entrypoint: source.entrypoint, status: 'partial', retrieved: count, reason: null };
1330
- if (source.status === 'unavailable') return { entrypoint: source.entrypoint, status: 'unavailable', reason: source.reason ?? null };
1331
- return { entrypoint: source.entrypoint, status: 'pending', reason: null };
1332
- });
1333
- return historyBanner(doc, {
1334
- label: '読み込み中',
1335
- tone: 'info',
1336
- role: 'status',
1337
- sources,
1338
- note: slow
1339
- ? '時間がかかっています。届いた分だけ先に見られます。'
1340
- : '取得元ごとに届いた順に表示します',
1341
- });
1325
+ return workspaceLoadingCard(doc, { title: 'ロード中' });
1342
1326
  }
1343
1327
  if (home?.status === 'partial') {
1344
1328
  return historyBanner(doc, {
@@ -1522,12 +1506,6 @@ function summaryLine(doc, aggregate, { loading = false } = {}) {
1522
1506
  line.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-skeleton is-inline', attrs: { 'aria-hidden': 'true' } }));
1523
1507
  line.append(makeWorkspaceElement(doc, 'span', { text: ' 取得済み' }));
1524
1508
  }
1525
- if (!complete) {
1526
- line.append(makeWorkspaceElement(doc, 'span', {
1527
- className: 'bb-jh-summary-note',
1528
- text: loading ? '(読み込み中・全件数は未確認)' : '(全件数は未確認)',
1529
- }));
1530
- }
1531
1509
  const references = loading ? null : (aggregate?.stats?.referenceCount ?? aggregate?.stats?.references);
1532
1510
  const confirmed = loading ? null : (aggregate?.stats?.confirmedOutcomes ?? aggregate?.stats?.outcomes);
1533
1511
  line.append(makeWorkspaceElement(doc, 'span', { text: ' ・ 使った参照 ' }));
@@ -1578,6 +1556,7 @@ export function createJudgmentHistoryUI({
1578
1556
  page,
1579
1557
  autoLoad = true,
1580
1558
  pageHeader = true,
1559
+ notificationRoot = null,
1581
1560
  now = new Date(),
1582
1561
  skeletonDelayMs = 300,
1583
1562
  slowDelayMs = 10000,
@@ -1621,6 +1600,52 @@ export function createJudgmentHistoryUI({
1621
1600
  slowTimer: null,
1622
1601
  };
1623
1602
 
1603
+ let filterPopover = null;
1604
+ let filterExpanded = false;
1605
+ let drawer = null;
1606
+ let drawerContent = null;
1607
+ let drawerKey = null;
1608
+ let suppressDrawerClose = false;
1609
+ function disposeDrawer() {
1610
+ suppressDrawerClose = true;
1611
+ drawer?.dispose();
1612
+ drawer = null; drawerContent = null; drawerKey = null;
1613
+ suppressDrawerClose = false;
1614
+ }
1615
+ const nativeDrawer = Boolean(doc.body && typeof doc.createElement('dialog').showModal === 'function');
1616
+ const previousRailHidden = rail?.hidden;
1617
+ if (nativeDrawer && rail) rail.hidden = true;
1618
+ const notifications = makeWorkspaceElement(doc, 'div', {
1619
+ className: 'bb-ws-error-stack bb-jh-notifications', attrs: { 'aria-label': '判断履歴の通知' },
1620
+ });
1621
+ if (notificationRoot) notificationRoot.append(notifications);
1622
+ let loadingCard = null;
1623
+ let dismissLoading = null;
1624
+ let statusCard = null;
1625
+ let statusKey = null;
1626
+
1627
+ function updateNotifications(loading) {
1628
+ if (loading) {
1629
+ dismissLoading?.(); dismissLoading = null;
1630
+ if (!loadingCard) loadingCard = workspaceLoadingCard(doc, { title: 'ロード中' });
1631
+ if (loadingCard.parentNode !== notifications) notifications.append(loadingCard);
1632
+ } else if (loadingCard && !dismissLoading) {
1633
+ dismissLoading = workspaceDismissNotification(loadingCard);
1634
+ }
1635
+ const home = state.home;
1636
+ const key = loading ? null : JSON.stringify([state.phase, state.error, home?.status, home?.coverage]);
1637
+ if (statusKey !== key) {
1638
+ statusCard?.remove?.();
1639
+ // Small host DOM adapters may not implement Element.remove().
1640
+ if (statusCard?.parentNode === notifications && typeof statusCard.remove !== 'function') {
1641
+ notifications.replaceChildren(...notifications.children.filter(node => node !== statusCard));
1642
+ }
1643
+ statusKey = key;
1644
+ statusCard = loading ? null : statusNotice(doc, state.phase, state.error, home, () => void load());
1645
+ if (statusCard) notifications.append(statusCard);
1646
+ }
1647
+ }
1648
+
1624
1649
  function clearLoadTimers() {
1625
1650
  if (state.skeletonTimer !== null) {
1626
1651
  clearTimeout(state.skeletonTimer);
@@ -1664,12 +1689,13 @@ export function createJudgmentHistoryUI({
1664
1689
  state.selectedKey = null;
1665
1690
  state.detailPhase = 'idle';
1666
1691
  state.detailError = null;
1692
+ if (drawer) drawer.close();
1667
1693
  render();
1668
1694
  return true;
1669
1695
  }
1670
1696
 
1671
1697
  function handleKeyDown(event) {
1672
- if (event?.key === 'Escape') closeSelected();
1698
+ if (event?.key === 'Escape' && !drawer) closeSelected();
1673
1699
  }
1674
1700
 
1675
1701
  root.addEventListener?.('keydown', handleKeyDown);
@@ -1908,7 +1934,18 @@ export function createJudgmentHistoryUI({
1908
1934
 
1909
1935
  function render() {
1910
1936
  if (state.disposed) return;
1911
- if (state.phase === 'loading' && !state.showSkeleton && !state.arrived) return;
1937
+ updateNotifications(state.phase === 'loading');
1938
+ if (state.phase === 'loading' && !state.showSkeleton && !state.arrived) {
1939
+ if (!notificationRoot && notifications.parentNode !== root) root.append(notifications);
1940
+ return;
1941
+ }
1942
+ const focusedFilter = filterExpanded && filterPopover
1943
+ ? findFirst(filterPopover.panel, node => node === doc.activeElement) : null;
1944
+ const filterFocus = focusedFilter && {
1945
+ label: attributeValue(focusedFilter, 'aria-label'), period: attributeValue(focusedFilter, 'data-period'),
1946
+ };
1947
+ filterPopover?.destroy();
1948
+ filterPopover = null;
1912
1949
  const previousSearchInput = findFirst(root, (node) => node.tagName === 'INPUT');
1913
1950
  const previousSearchSelection = previousSearchInput && Number.isInteger(previousSearchInput.selectionStart)
1914
1951
  ? { start: previousSearchInput.selectionStart, end: previousSearchInput.selectionEnd ?? previousSearchInput.selectionStart }
@@ -1936,21 +1973,18 @@ export function createJudgmentHistoryUI({
1936
1973
  source: context.source,
1937
1974
  }));
1938
1975
  }
1939
- const notice = statusNotice(doc, state.phase, state.error, loading ? (state.arrived ?? state.home) : state.home, () => void load(), {
1940
- loading,
1941
- slow: state.slow,
1942
- arrived: state.arrived,
1943
- });
1944
- if (notice) wrapper.append(notice);
1976
+ if (!notificationRoot) wrapper.append(notifications);
1945
1977
  const failed = state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'error' || state.home?.status === 'invalid';
1946
1978
  if (failed) {
1947
1979
  wrapper.append(workspaceDetailEmpty(doc, { mark: '…', title: '判断履歴を確認できません', text: '再試行すると最新の状態を確認します。' }));
1948
1980
  root.replaceChildren(wrapper);
1949
- renderRail(doc, rail, null, null);
1981
+ if (drawer) disposeDrawer();
1982
+ if (!nativeDrawer) renderRail(doc, rail, null, null);
1950
1983
  return;
1951
1984
  }
1952
1985
  const controls = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-controls' });
1953
- controls.append(periodButtons(doc, state.period, (period) => {
1986
+ const filterFields = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-filter-fields' });
1987
+ filterFields.append(periodButtons(doc, state.period, (period) => {
1954
1988
  state.period = period;
1955
1989
  state.selectedKey = null;
1956
1990
  state.cursor = null;
@@ -1961,7 +1995,7 @@ export function createJudgmentHistoryUI({
1961
1995
  }
1962
1996
  }));
1963
1997
  const showNormalFilters = loading ? sourceHome?.mode !== 'legacy' : normalMode();
1964
- if (showNormalFilters) controls.append(normalFilterControls(doc, sourceHome ?? state.home, state, handleNormalFilterChange));
1998
+ if (showNormalFilters) filterFields.append(normalFilterControls(doc, sourceHome ?? state.home, state, handleNormalFilterChange));
1965
1999
  controls.append(createSearch(doc, state.query, (query) => {
1966
2000
  state.query = String(query);
1967
2001
  state.focusSearch = true;
@@ -1970,28 +2004,41 @@ export function createJudgmentHistoryUI({
1970
2004
  if (searchable) state.aggregate = aggregateJudgmentHistory(searchable, { now: readNow(), period: state.period, query: state.query });
1971
2005
  render();
1972
2006
  }));
2007
+ filterPopover = workspaceFilterPopover(doc, {
2008
+ content: filterFields, open: filterExpanded,
2009
+ onToggle: value => { filterExpanded = value; },
2010
+ });
2011
+ controls.append(filterPopover.root);
1973
2012
  const toolbarStatus = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-toolbar-status' });
1974
- if (loading) {
1975
- toolbarStatus.append(makeWorkspaceElement(doc, 'span', {
1976
- className: 'bb-jh-checked',
1977
- text: '確認しています…',
1978
- attrs: { role: 'status' },
1979
- }));
1980
- } else if (state.checkedAt) {
2013
+ if (state.checkedAt) {
1981
2014
  const stamp = formatCheckedAt(state.checkedAt);
1982
- if (stamp) toolbarStatus.append(makeWorkspaceElement(doc, 'span', { className: 'bb-jh-checked', text: `${stamp}に確認` }));
2015
+ if (stamp) toolbarStatus.append(makeWorkspaceElement(doc, 'span', {
2016
+ className: 'bb-jh-checked', text: stamp, attrs: { 'aria-label': `${stamp}に確認` },
2017
+ }));
1983
2018
  }
1984
- toolbarStatus.append(workspaceButton(doc, {
1985
- text: '再読み込み',
1986
- variant: 'quiet',
1987
- disabled: loading,
1988
- onClick: () => void load(),
1989
- attrs: { 'aria-label': '判断履歴を再読み込み', 'data-action': 'reload' },
1990
- }));
2019
+ const reloadButton = workspaceButton(doc, {
2020
+ variant: 'quiet', disabled: loading, onClick: () => void load(),
2021
+ attrs: { 'aria-label': '判断履歴を再読み込み', title: '再読み込み', 'data-action': 'reload' },
2022
+ });
2023
+ reloadButton.className += ' bb-jh-refresh';
2024
+ // Text-independent vector keeps the refresh control available in standalone hosts.
2025
+ const refreshMark = doc.createElementNS?.('http://www.w3.org/2000/svg', 'svg')
2026
+ ?? makeWorkspaceElement(doc, 'span', { text: '↻' });
2027
+ refreshMark.setAttribute('class', 'bb-jh-refresh-mark'); refreshMark.setAttribute('aria-hidden', 'true');
2028
+ if (doc.createElementNS) {
2029
+ refreshMark.setAttribute('viewBox', '0 0 24 24'); refreshMark.setAttribute('fill', 'none');
2030
+ refreshMark.setAttribute('stroke', 'currentColor'); refreshMark.setAttribute('stroke-width', '1.7');
2031
+ refreshMark.setAttribute('stroke-linecap', 'round'); refreshMark.setAttribute('stroke-linejoin', 'round');
2032
+ const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
2033
+ path.setAttribute('d', 'M20 7v5h-5M4 17v-5h5M6.1 6.1a8 8 0 0 1 13.2 3.2M4.7 14.7a8 8 0 0 0 13.2 3.2');
2034
+ refreshMark.append(path);
2035
+ }
2036
+ reloadButton.append(refreshMark);
2037
+ toolbarStatus.append(reloadButton);
1991
2038
  controls.append(toolbarStatus);
1992
2039
  wrapper.append(controls);
1993
2040
  wrapper.append(summaryLine(doc, aggregate, { loading }));
1994
- const emptyText = state.home?.status === 'partial' || (normalMode() && !aggregate.countComplete)
2041
+ const emptyText = loading ? '' : state.home?.status === 'partial' || (normalMode() && !aggregate.countComplete)
1995
2042
  ? 'この期間に取得できた判断履歴はありません。全件数が未確認のため0件とは確認できません。'
1996
2043
  : 'この期間の判断履歴はありません。';
1997
2044
  const dataRows = makeRows(doc, aggregate, state.selectedKey, (key) => {
@@ -2023,6 +2070,12 @@ export function createJudgmentHistoryUI({
2023
2070
  replaceNode(nextSearchInput, previousSearchInput);
2024
2071
  }
2025
2072
  root.replaceChildren(wrapper);
2073
+ if (filterExpanded && filterFocus) {
2074
+ const field = findFirst(filterPopover.panel, node => filterFocus.label
2075
+ ? attributeValue(node, 'aria-label') === filterFocus.label
2076
+ : filterFocus.period && attributeValue(node, 'data-period') === filterFocus.period);
2077
+ field?.focus?.();
2078
+ }
2026
2079
  if (state.focusSearch || previousSearchFocused) {
2027
2080
  const input = findFirst(root, (node) => node.tagName === 'INPUT');
2028
2081
  input?.focus?.();
@@ -2048,8 +2101,38 @@ export function createJudgmentHistoryUI({
2048
2101
  onCopyReference: (payload) => void copyReference(payload),
2049
2102
  }
2050
2103
  : {};
2051
- renderRail(doc, rail, selected, selectedDetail, selected ? closeSelected : null, { ...railOptions, loading });
2052
- if (state.lastFocusedKey) {
2104
+ if (nativeDrawer) {
2105
+ if (selected) {
2106
+ if (!drawer || drawerKey !== selected.key) {
2107
+ if (drawer) disposeDrawer();
2108
+ drawerKey = selected.key;
2109
+ drawerContent = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-detail' });
2110
+ const selectedKey = selected.key;
2111
+ drawer = workspaceDialog(doc, {
2112
+ title: '判断の詳細', content: drawerContent, presentation: 'drawer',
2113
+ opener: () => findFirst(root, node => attributeValue(node, 'data-key') === selectedKey),
2114
+ onClose: () => {
2115
+ if (suppressDrawerClose) return;
2116
+ drawer = null; drawerContent = null; drawerKey = null;
2117
+ if (state.disposed) return;
2118
+ state.selectedKey = null; state.detailPhase = 'idle'; state.detailError = null;
2119
+ state.lastFocusedKey = selectedKey; render();
2120
+ },
2121
+ });
2122
+ }
2123
+ const detailFocus = findFirst(drawerContent, node => node === doc.activeElement);
2124
+ const detailLabel = attributeValue(detailFocus, 'aria-label');
2125
+ const detailSelection = Number.isInteger(detailFocus?.selectionStart)
2126
+ ? [detailFocus.selectionStart, detailFocus.selectionEnd] : null;
2127
+ renderRail(doc, drawerContent, selected, selectedDetail, null, { ...railOptions, loading });
2128
+ if (detailLabel) {
2129
+ const field = findFirst(drawerContent, node => attributeValue(node, 'aria-label') === detailLabel);
2130
+ field?.focus?.();
2131
+ if (detailSelection) field?.setSelectionRange?.(...detailSelection);
2132
+ }
2133
+ } else if (drawer) drawer.close();
2134
+ } else renderRail(doc, rail, selected, selectedDetail, selected ? closeSelected : null, { ...railOptions, loading });
2135
+ if (state.lastFocusedKey && !drawer) {
2053
2136
  const row = findFirst(root, (node) => attributeValue(node, 'data-key') === state.lastFocusedKey);
2054
2137
  row?.focus?.();
2055
2138
  state.lastFocusedKey = null;
@@ -2169,10 +2252,15 @@ export function createJudgmentHistoryUI({
2169
2252
  state.disposed = true;
2170
2253
  state.generation += 1;
2171
2254
  clearLoadTimers();
2255
+ filterPopover?.destroy();
2256
+ dismissLoading?.();
2257
+ notifications.remove?.();
2258
+ disposeDrawer();
2172
2259
  root.removeEventListener?.('keydown', handleKeyDown);
2173
2260
  rail?.removeEventListener?.('keydown', handleKeyDown);
2174
2261
  root.replaceChildren();
2175
2262
  rail?.replaceChildren();
2263
+ if (nativeDrawer && rail) rail.hidden = previousRailHidden;
2176
2264
  }
2177
2265
 
2178
2266
  const controller = { load, render, dispose, openDecision, get state() { return { ...state }; } };
@@ -451,8 +451,11 @@ export function createLocalWebShell({
451
451
  workspace.append(mobileBar, main);
452
452
  const railAside = makeElement(doc, 'aside', { className: 'bb-shell-rail', attrs: { 'aria-label': '選択中の項目' } });
453
453
  const rails = new Map();
454
+ const nativeDrawers = Boolean(doc.body && typeof doc.createElement('dialog').showModal === 'function');
454
455
  for (const screen of screens) {
455
- if (!screen.rail) continue;
456
+ // The built-in history uses a modal drawer in browsers, with a rail fallback
457
+ // for minimal hosts. Do not reserve an empty desktop column for the drawer.
458
+ if (!screen.rail || (nativeDrawers && screen.mount === mountToday)) continue;
456
459
  const railSlot = makeElement(doc, 'div', { className: 'bb-shell-rail-slot', attrs: { 'data-rail': screen.id } });
457
460
  railSlot.hidden = true;
458
461
  rails.set(screen.id, railSlot);
@@ -119,3 +119,146 @@
119
119
  display: none;
120
120
  }
121
121
  }
122
+
123
+ /* Marks, counts and badges (2026-10). A person is a round mark, a company a
124
+ rounded square; both keep one tone per id. Counts sit next to each group
125
+ title, and the jump links at the top move to the people or company part. */
126
+ .bb-pc-jump {
127
+ display: flex;
128
+ gap: 4px;
129
+ margin: 4px 0 18px;
130
+ padding: 3px;
131
+ width: max-content;
132
+ border-radius: 9px;
133
+ background: var(--bb-color-canvas);
134
+ }
135
+
136
+ .bb-pc-jump-link {
137
+ display: inline-flex;
138
+ align-items: center;
139
+ gap: 6px;
140
+ min-height: 28px;
141
+ padding: 0 12px;
142
+ border: 0;
143
+ border-radius: 6px;
144
+ background: transparent;
145
+ color: var(--bb-color-ink);
146
+ font: inherit;
147
+ font-size: 13px;
148
+ font-weight: 600;
149
+ cursor: pointer;
150
+ }
151
+
152
+ .bb-pc-jump-link:hover {
153
+ background: var(--bb-color-paper);
154
+ box-shadow: 0 1px 2px rgb(32 33 36 / 10%);
155
+ }
156
+
157
+ .bb-pc-count {
158
+ color: var(--bb-color-muted);
159
+ font-size: 12px;
160
+ font-weight: 500;
161
+ font-variant-numeric: tabular-nums;
162
+ }
163
+
164
+ .bb-graph .bb-pc-group-title,
165
+ .bb-graph .bb-pc-company-title {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 8px;
169
+ }
170
+
171
+ .bb-graph .bb-pc-company-title {
172
+ color: var(--bb-color-ink);
173
+ font-weight: 600;
174
+ }
175
+
176
+ .bb-pc-mark {
177
+ display: inline-grid;
178
+ flex: 0 0 auto;
179
+ place-items: center;
180
+ width: 24px;
181
+ height: 24px;
182
+ margin-right: 10px;
183
+ color: var(--bb-pc-mark-ink);
184
+ background: var(--bb-pc-mark-bg);
185
+ font-size: 11px;
186
+ font-weight: 700;
187
+ line-height: 1;
188
+ }
189
+
190
+ .bb-pc-person-mark { border-radius: 50%; }
191
+ .bb-pc-company-mark { border-radius: 6px; }
192
+ .bb-pc-company-title .bb-pc-mark,
193
+ .bb-pc-company-chip .bb-pc-mark { width: 18px; height: 18px; margin-right: 0; border-radius: 5px; font-size: 10px; }
194
+
195
+ .bb-pc-tone-0 { --bb-pc-mark-bg: #fde7e9; --bb-pc-mark-ink: #b4233a; }
196
+ .bb-pc-tone-1 { --bb-pc-mark-bg: #e3effd; --bb-pc-mark-ink: #1f5fbf; }
197
+ .bb-pc-tone-2 { --bb-pc-mark-bg: #e2f5ea; --bb-pc-mark-ink: #157046; }
198
+ .bb-pc-tone-3 { --bb-pc-mark-bg: #fff1d6; --bb-pc-mark-ink: #9a5a00; }
199
+ .bb-pc-tone-4 { --bb-pc-mark-bg: #efe7fd; --bb-pc-mark-ink: #6d36c9; }
200
+ .bb-pc-tone-5 { --bb-pc-mark-bg: #e0f4f6; --bb-pc-mark-ink: #0d6e7a; }
201
+
202
+ .bb-pc-people-ledger .bb-ws-ledger-object,
203
+ .bb-pc-company-ledger .bb-ws-ledger-object {
204
+ display: flex;
205
+ align-items: center;
206
+ min-width: 0;
207
+ }
208
+
209
+ .bb-pc-people-ledger .bb-ws-ledger-object strong,
210
+ .bb-pc-company-ledger .bb-ws-ledger-object strong {
211
+ overflow: hidden;
212
+ font-weight: 600;
213
+ text-overflow: ellipsis;
214
+ white-space: nowrap;
215
+ }
216
+
217
+ .bb-pc-company-chips {
218
+ display: flex;
219
+ flex-wrap: wrap;
220
+ gap: 4px 12px;
221
+ }
222
+
223
+ .bb-pc-company-chip {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ gap: 7px;
227
+ min-width: 0;
228
+ }
229
+
230
+ .bb-pc-kinds {
231
+ display: flex;
232
+ flex-wrap: wrap;
233
+ gap: 4px;
234
+ }
235
+
236
+ .bb-pc-kind {
237
+ display: inline-flex;
238
+ align-items: center;
239
+ gap: 5px;
240
+ padding: 1px 8px;
241
+ border-radius: 5px;
242
+ background: var(--bb-color-canvas);
243
+ color: var(--bb-color-muted);
244
+ font-size: 12px;
245
+ font-weight: 600;
246
+ }
247
+
248
+ .bb-pc-kind::before {
249
+ content: '';
250
+ width: 6px;
251
+ height: 6px;
252
+ border-radius: 50%;
253
+ background: currentColor;
254
+ }
255
+
256
+ .bb-pc-kind-customer { background: #e2f5ea; color: #157046; }
257
+ .bb-pc-kind-partner { background: #e3effd; color: #1f5fbf; }
258
+ .bb-pc-kind-prospect { background: #fff1d6; color: #9a5a00; }
259
+
260
+ .bb-pc-people-ledger .bb-ws-ledger-row:not(.bb-ws-ledger-head),
261
+ .bb-pc-company-ledger .bb-ws-ledger-row:not(.bb-ws-ledger-head) {
262
+ align-items: center;
263
+ min-height: 46px;
264
+ }
@@ -117,6 +117,21 @@ function ownKey(table, key) {
117
117
  return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
118
118
  }
119
119
 
120
+ /** First visible character of a name for its mark, or a placeholder. */
121
+ function nameInitial(name) {
122
+ const text = String(name ?? '').trim();
123
+ const latin = text.split(/\s+/u).filter(Boolean);
124
+ if (/^[A-Za-z]/u.test(text) && latin.length > 1) return `${latin[0][0]}${latin[1][0]}`.toUpperCase();
125
+ return ([...text][0] ?? '?').toUpperCase();
126
+ }
127
+
128
+ /** A stable tone (0-5) per id so a person or company keeps its color. */
129
+ function markTone(id) {
130
+ let hash = 0;
131
+ for (const character of String(id ?? '')) hash = (hash * 31 + character.codePointAt(0)) >>> 0;
132
+ return hash % 6;
133
+ }
134
+
120
135
  /** The read is `ok` only when every person and company is well formed. */
121
136
  export function normalizePeopleAndCompanies(result) {
122
137
  if (result?.state !== 'ok') return result ?? INVALID;
@@ -269,8 +284,8 @@ export function createPeopleAndCompaniesView({
269
284
  selected: person.id === state.detail?.id,
270
285
  onSelect: (id) => void controller.openEntity(id),
271
286
  cells: [
272
- { primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
273
- person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : companyCell(person, ownId),
287
+ { primary: person.isOwner ? `${person.name}(あなた)` : person.name, leading: personMark(person) },
288
+ person.companyIds.length > 0 ? companyChips(person.companyIds) : companyCell(person, ownId),
274
289
  person.accountableProjectCount > 0
275
290
  ? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
276
291
  : `${person.projectCount}件`,
@@ -280,6 +295,67 @@ export function createPeopleAndCompaniesView({
280
295
  }));
281
296
  }
282
297
 
298
+ function personMark(person) {
299
+ return makeElement(doc, 'span', {
300
+ className: `bb-pc-mark bb-pc-person-mark bb-pc-tone-${markTone(person.id)}`,
301
+ text: nameInitial(person.name),
302
+ attrs: { 'aria-hidden': 'true' },
303
+ });
304
+ }
305
+
306
+ function companyMark(id, name) {
307
+ return makeElement(doc, 'span', {
308
+ className: `bb-pc-mark bb-pc-company-mark bb-pc-tone-${markTone(id)}`,
309
+ text: nameInitial(name),
310
+ attrs: { 'aria-hidden': 'true' },
311
+ });
312
+ }
313
+
314
+ function companyChips(ids) {
315
+ const list = makeElement(doc, 'span', { className: 'bb-pc-company-chips' });
316
+ for (const id of ids) {
317
+ const chip = makeElement(doc, 'span', { className: 'bb-pc-company-chip' });
318
+ chip.append(companyMark(id, nameOf(id)), makeElement(doc, 'span', { text: nameOf(id) }));
319
+ list.append(chip);
320
+ }
321
+ return list;
322
+ }
323
+
324
+ function kindBadges(company) {
325
+ const kinds = company.kinds ?? [];
326
+ if (kinds.length === 0) return '';
327
+ const list = makeElement(doc, 'span', { className: 'bb-pc-kinds' });
328
+ for (const item of kinds) {
329
+ list.append(makeElement(doc, 'span', {
330
+ className: `bb-pc-kind bb-pc-kind-${Object.hasOwn(COMPANY_KIND_LABELS, item.kind) ? item.kind : 'other'}`,
331
+ text: ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind,
332
+ }));
333
+ }
334
+ return list;
335
+ }
336
+
337
+ function groupTitle(tag, text, count, { className = 'bb-pc-group-title', leading } = {}) {
338
+ const title = makeElement(doc, tag, { className });
339
+ if (leading) title.append(leading);
340
+ title.append(makeElement(doc, 'span', { text }), makeElement(doc, 'span', { className: 'bb-pc-count', text: String(count) }));
341
+ return title;
342
+ }
343
+
344
+ function jumpLinks(payload) {
345
+ const people = payload.people.filter((person) => person.active).length;
346
+ const companies = payload.companies.filter((company) => company.active).length;
347
+ const nav = makeElement(doc, 'nav', { className: 'bb-pc-jump', attrs: { 'aria-label': '人と会社の段' } });
348
+ for (const [label, count, target] of [['人', people, 'bb-pc-people'], ['会社', companies, 'bb-pc-companies']]) {
349
+ const link = button(doc, '', () => {
350
+ const node = typeof doc.getElementById === 'function' ? doc.getElementById(target) : null;
351
+ node?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
352
+ }, { className: 'bb-pc-jump-link', attrs: { 'aria-label': `${label} ${count}件の段へ移る` } });
353
+ link.append(makeElement(doc, 'span', { text: label }), makeElement(doc, 'span', { className: 'bb-pc-count', text: String(count) }));
354
+ nav.append(link);
355
+ }
356
+ return nav;
357
+ }
358
+
283
359
  function orgNoteText(note) {
284
360
  return `記録上のメモ: ${note}(所属ではありません)`;
285
361
  }
@@ -324,7 +400,7 @@ export function createPeopleAndCompaniesView({
324
400
  }
325
401
 
326
402
  function peopleSection(payload) {
327
- const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
403
+ const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人', id: 'bb-pc-people' } });
328
404
  section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
329
405
  if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
330
406
  const groups = groupPeople(payload);
@@ -337,22 +413,22 @@ export function createPeopleAndCompaniesView({
337
413
  }));
338
414
  }
339
415
  if (groups.ownCompanyId) {
340
- section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: `社内(${nameOf(groups.ownCompanyId)})` }));
416
+ section.append(groupTitle('h3', `社内(${nameOf(groups.ownCompanyId)})`, groups.internal.length));
341
417
  section.append(peopleLedger('社内の人', groups.internal, '自社に所属する人の記録はありません。'));
342
- section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '社外' }));
418
+ section.append(groupTitle('h3', '社外', groups.external.reduce((sum, group) => sum + group.people.length, 0)));
343
419
  if (groups.external.length === 0) section.append(makeElement(doc, 'p', { className: 'bb-pc-empty', text: 'ほかの会社に所属する人の記録はありません。' }));
344
420
  for (const group of groups.external) {
345
- section.append(makeElement(doc, 'h4', { className: 'bb-pc-company-title', text: group.company.name }));
421
+ section.append(groupTitle('h4', group.company.name, group.people.length, { className: 'bb-pc-company-title', leading: companyMark(group.company.id, group.company.name) }));
346
422
  section.append(peopleLedger(`${group.company.name}の人`, group.people));
347
423
  }
348
424
  }
349
- section.append(makeElement(doc, 'h3', { className: 'bb-pc-group-title', text: '所属未確認' }));
425
+ section.append(groupTitle('h3', '所属未確認', groups.unknown.length));
350
426
  section.append(peopleLedger('所属未確認の人', groups.unknown, '所属が分からない人はいません。'));
351
427
  return section;
352
428
  }
353
429
 
354
430
  function companySection(payload) {
355
- const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
431
+ const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社', id: 'bb-pc-companies' } });
356
432
  section.append(workspaceSectionTitle(doc, {
357
433
  title: '会社',
358
434
  lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
@@ -369,8 +445,8 @@ export function createPeopleAndCompaniesView({
369
445
  selected: company.id === state.detail?.id,
370
446
  onSelect: (id) => void controller.openEntity(id),
371
447
  cells: [
372
- { primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
373
- companyKindLabel(company),
448
+ { primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, leading: companyMark(company.id, company.name) },
449
+ kindBadges(company),
374
450
  `${company.memberIds.length}人`,
375
451
  company.relatedProjects.length > 0
376
452
  ? `${company.projectIds.length}件(関わる ${company.relatedProjects.length}件)`
@@ -408,7 +484,7 @@ export function createPeopleAndCompaniesView({
408
484
  return [...children, graphHostEmptyNotice(doc, emptyNotice)
409
485
  ?? workspaceNotice(doc, { label: '未登録', tone: 'info', role: 'status', text: 'Graphに人と会社の記録はまだありません。' })];
410
486
  }
411
- return [...children, peopleSection(payload), companySection(payload), endedNote(payload)];
487
+ return [...children, jumpLinks(payload), peopleSection(payload), companySection(payload), endedNote(payload)];
412
488
  }
413
489
 
414
490
  // -------------------------------------------------------------------------
@@ -530,3 +530,46 @@
530
530
  .bb-ws-error-card :is(summary,button):focus-visible { outline: 2px solid var(--bb-color-notice-text); outline-offset: 2px; }
531
531
  .bb-ws-inline-error { height: 15px; max-height: 15px; margin: 4px 0; padding: 0; overflow: hidden; color: var(--bb-color-inline-error); font-size: 11px; line-height: 15px; }
532
532
  @media (prefers-reduced-motion: reduce) { .bb-ws-help-bubble { transition: none; } }
533
+
534
+ /* One native-dialog drawer presentation for shared parts and host-owned task dialogs. */
535
+ .bb-ws-drawer {
536
+ box-sizing: border-box; width: min(640px,100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh;
537
+ margin: 0 0 0 auto; padding: 0; border: 0; border-left: 1px solid var(--bb-color-border);
538
+ border-radius: 0; background: var(--bb-color-panel); color: var(--bb-color-ink); box-shadow: var(--bb-shadow-drawer); overflow: auto;
539
+ }
540
+ .bb-ws-drawer[open] { animation: bb-ws-drawer-in 180ms ease-out; }
541
+ .bb-ws-drawer::backdrop { background: var(--bb-color-drawer-overlay); }
542
+ .bb-ws-drawer.is-closing { animation: bb-ws-drawer-out 280ms ease-in both; pointer-events: none; }
543
+ .bb-ws-drawer.is-closing::backdrop { animation: bb-ws-drawer-backdrop-out 280ms ease-in both; }
544
+ .bb-ws-drawer .bb-ws-dialog-head { align-items: center; padding: 14px 16px 14px 28px; }
545
+ .bb-ws-drawer .bb-ws-dialog-head h2 { font-weight: 650; line-height: 1.35; }
546
+ .bb-ws-drawer .bb-ws-dialog-body { padding: 18px 28px 32px; }
547
+ @keyframes bb-ws-drawer-in { from { transform: translateX(24px); opacity: 0; } to { transform: none; opacity: 1; } }
548
+ @keyframes bb-ws-drawer-out { from { transform: translateX(0); opacity: 1; } to { transform: translateX(32px); opacity: 0; } }
549
+ @keyframes bb-ws-drawer-backdrop-out { from { background: var(--bb-color-drawer-overlay); } to { background: var(--bb-color-drawer-overlay-clear); } }
550
+ .bb-ws-filter-anchor { position: relative; display: inline-flex; flex: none; }
551
+ .bb-ws-filter-anchor > .bb-ws-button::after { content: '⌄'; margin-left: var(--bb-space-2); }
552
+ .bb-ws-filter-popover {
553
+ position: absolute; z-index: 40; top: calc(100% + 8px); right: 0; display: grid; gap: var(--bb-space-4);
554
+ box-sizing: border-box; width: 360px; max-width: calc(100vw - 48px); padding: 20px;
555
+ border: 1px solid var(--bb-color-border); border-radius: 12px; background: var(--bb-color-panel); color: var(--bb-color-ink);
556
+ box-shadow: var(--bb-shadow-popover); max-height: calc(100dvh - 220px); overflow-y: auto;
557
+ transform-origin: top right; animation: bb-ws-filter-enter 180ms ease-out;
558
+ }
559
+ .bb-ws-filter-popover[hidden] { display: none !important; }
560
+ @keyframes bb-ws-filter-enter { from { opacity: 0; transform: translateY(-6px) scaleY(.96); } to { opacity: 1; transform: none; } }
561
+ .bb-ws-loading-card { align-items: center; border-left-color: var(--bb-color-notice-control-border); }
562
+ .bb-ws-loading-spinner { position: relative; display: block; flex: none; width: 14px; height: 14px; }
563
+ .bb-ws-loading-spinner::before {
564
+ content: ''; position: absolute; top: 1px; left: 1px; box-sizing: border-box; width: 12px; height: 12px;
565
+ border: 1.5px solid var(--bb-color-notice-control-border); border-top-color: var(--bb-color-notice-text); border-radius: 50%;
566
+ transform-origin: center; animation: bb-ws-spinner 1s linear infinite;
567
+ }
568
+ @keyframes bb-ws-spinner { to { transform: rotate(360deg); } }
569
+ .bb-ws-error-card.is-dismissing { animation: bb-ws-notification-out 450ms ease both; pointer-events: none; }
570
+ @keyframes bb-ws-notification-out { to { opacity: 0; transform: translateX(28px); } }
571
+ @media (max-width:760px) { .bb-ws-drawer { width: 100vw; border-left: 0; } }
572
+ @media (prefers-reduced-motion:reduce) {
573
+ .bb-ws-drawer[open], .bb-ws-drawer.is-closing, .bb-ws-drawer.is-closing::backdrop,
574
+ .bb-ws-filter-popover, .bb-ws-loading-spinner::before, .bb-ws-error-card.is-dismissing { animation: none; }
575
+ }
@@ -256,23 +256,150 @@ export function workspaceHelp(doc, { text, label } = {}) {
256
256
  wrap.append(control, bubble); return wrap;
257
257
  }
258
258
 
259
- /** Native dialog keeps focus trapped, Escape close and focus restoration. */
260
- export function workspaceDialog(doc, { title, content, opener } = {}) {
259
+ const drawerClosings = new WeakMap();
260
+ const notificationClosings = new WeakMap();
261
+ const reducedMotion = node => node.ownerDocument?.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
262
+
263
+ /** Apply the same native-dialog presentation to host-owned and kit drawers. */
264
+ export function workspaceApplyDrawer(dialog) {
265
+ dialog.classList.add('bb-ws-drawer');
266
+ return dialog;
267
+ }
268
+
269
+ /** Keep the modal/focus trap alive until the exit motion finishes. Returns cancellation cleanup. */
270
+ export function workspaceCloseDrawer(dialog, finish) {
271
+ if (drawerClosings.has(dialog)) return drawerClosings.get(dialog);
272
+ if (!dialog.open || reducedMotion(dialog)) { finish(); return () => {}; }
273
+ let timer;
274
+ let active = true;
275
+ const cancel = () => {
276
+ if (!active) return;
277
+ active = false;
278
+ clearTimeout(timer);
279
+ dialog.removeEventListener('animationend', ended);
280
+ dialog.removeEventListener('close', cancel);
281
+ dialog.classList.remove('is-closing');
282
+ drawerClosings.delete(dialog);
283
+ };
284
+ const complete = () => { if (!active) return; cancel(); finish(); };
285
+ const ended = event => {
286
+ if (event.target === dialog && event.animationName === 'bb-ws-drawer-out') complete();
287
+ };
288
+ drawerClosings.set(dialog, cancel);
289
+ dialog.addEventListener('animationend', ended);
290
+ dialog.addEventListener('close', cancel);
291
+ dialog.classList.add('is-closing');
292
+ timer = setTimeout(complete, 360); // Animation cancellation / background-tab fallback.
293
+ return cancel;
294
+ }
295
+
296
+ /** Native modal, optionally a right drawer. close() animates; dispose() tears down immediately. */
297
+ export function workspaceDialog(doc, { title, content, opener, presentation = 'center', onClose } = {}) {
261
298
  const dialog = el(doc, 'dialog', { className: 'bb-ws-dialog' });
299
+ if (presentation === 'drawer') workspaceApplyDrawer(dialog);
262
300
  const headingId = `bb-ws-dialog-${++workspaceSequence}`;
263
301
  dialog.setAttribute('aria-labelledby', headingId);
264
302
  const head = el(doc, 'header', { className: 'bb-ws-dialog-head' });
303
+ const nativeClose = dialog.close.bind(dialog);
304
+ let disposed = false;
305
+ const cleanup = () => {
306
+ if (disposed) return;
307
+ disposed = true;
308
+ drawerClosings.get(dialog)?.();
309
+ dialog.remove();
310
+ try { onClose?.(); } finally {
311
+ const target = typeof opener === 'function' ? opener() : opener;
312
+ if (target?.isConnected) target.focus();
313
+ }
314
+ };
315
+ dialog.close = value => {
316
+ if (disposed) return;
317
+ const finish = () => { nativeClose(value); cleanup(); };
318
+ if (presentation === 'drawer') workspaceCloseDrawer(dialog, finish);
319
+ else finish();
320
+ };
321
+ dialog.dispose = () => {
322
+ if (disposed) return;
323
+ drawerClosings.get(dialog)?.();
324
+ nativeClose(); cleanup();
325
+ };
265
326
  const close = workspaceButton(doc, { text: '×', attrs: { 'aria-label': '閉じる' }, onClick: () => dialog.close() });
266
327
  head.append(el(doc, 'h2', { text: title, attrs: { id: headingId } }), close);
267
328
  const body = el(doc, 'div', { className: 'bb-ws-dialog-body' }); appendContent(body, doc, content);
268
329
  dialog.append(head, body); doc.body.append(dialog);
330
+ dialog.addEventListener('cancel', event => { event.preventDefault(); dialog.close(); });
269
331
  dialog.addEventListener('click', event => {
270
332
  if (event.target !== dialog) return;
271
333
  const rect = dialog.getBoundingClientRect();
272
334
  if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close();
273
335
  });
274
- dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); }, { once: true });
275
- dialog.showModal(); close.focus(); return dialog;
336
+ dialog.addEventListener('close', cleanup, { once: true });
337
+ try { dialog.showModal(); close.focus(); } catch (error) { cleanup(); throw error; }
338
+ return dialog;
339
+ }
340
+
341
+ /** Anchored advanced search. Hosts destroy it before replacing its controls. */
342
+ export function workspaceFilterPopover(doc, { content, open = false, onToggle, trigger, panel: suppliedPanel, initialFocus } = {}) {
343
+ const root = el(doc, 'span', { className: 'bb-ws-filter-anchor' });
344
+ const panel = suppliedPanel || el(doc, 'div', { className: 'bb-ws-filter-popover', attrs: { id: `bb-ws-filter-${++workspaceSequence}`, role: 'region', 'aria-label': '詳細検索' } });
345
+ if (!panel.id && !panel.getAttribute?.('id')) panel.setAttribute('id', `bb-ws-filter-${++workspaceSequence}`);
346
+ panel.classList?.add('bb-ws-filter-popover');
347
+ const control = trigger || workspaceButton(doc, { text: '詳細検索', variant: 'quiet' });
348
+ control.setAttribute('aria-controls', panel.id || panel.getAttribute?.('id') || panel.attributes?.id);
349
+ appendContent(panel, doc, content); root.append(control, panel);
350
+ let destroyed = false;
351
+ const removeListeners = () => { doc.removeEventListener?.('pointerdown', outside); doc.removeEventListener?.('keydown', escape); };
352
+ const setOpen = (value, focus = false) => {
353
+ if (destroyed) return;
354
+ removeListeners(); panel.hidden = !value; control.setAttribute('aria-expanded', String(value));
355
+ onToggle?.(value);
356
+ if (value) {
357
+ doc.addEventListener?.('pointerdown', outside); doc.addEventListener?.('keydown', escape);
358
+ if (focus) (typeof initialFocus === 'function' ? initialFocus() : initialFocus || panel.querySelector?.('input:not(:disabled),select:not(:disabled),button:not(:disabled)'))?.focus();
359
+ }
360
+ };
361
+ const outside = event => { if (!root.contains?.(event.target)) setOpen(false); };
362
+ const escape = event => {
363
+ if (event.key !== 'Escape') return;
364
+ event.preventDefault(); event.stopPropagation(); setOpen(false); control.focus();
365
+ };
366
+ const toggle = () => setOpen(panel.hidden, true);
367
+ control.addEventListener('click', toggle); setOpen(open);
368
+ return { root, control, panel, destroy() {
369
+ destroyed = true; removeListeners(); control.removeEventListener?.('click', toggle);
370
+ panel.hidden = true; control.setAttribute('aria-expanded', 'false');
371
+ } };
372
+ }
373
+
374
+ /** Compact notification with a fixed-center, purely visual spinner. */
375
+ export function workspaceLoadingCard(doc, { title = 'ロード中' } = {}) {
376
+ const card = el(doc, 'section', { className: 'bb-ws-error-card bb-ws-loading-card', attrs: { role: 'status', 'aria-live': 'polite' } });
377
+ card.append(el(doc, 'span', { className: 'bb-ws-loading-spinner', attrs: { 'aria-hidden': 'true' } }), el(doc, 'span', { text: title }));
378
+ return card;
379
+ }
380
+
381
+ /** Fade right then remove. Calling the returned cancel function restores a reusable card. */
382
+ export function workspaceDismissNotification(card, onDismiss) {
383
+ const remove = () => {
384
+ if (typeof card.remove === 'function') card.remove();
385
+ else if (card.parentNode?.removeChild) card.parentNode.removeChild(card);
386
+ else if (card.parentNode?.replaceChildren) card.parentNode.replaceChildren(...Array.from(card.parentNode.children).filter(node => node !== card));
387
+ };
388
+ if (notificationClosings.has(card)) return notificationClosings.get(card);
389
+ if (!card.classList || reducedMotion(card)) { remove(); onDismiss?.(); return () => {}; }
390
+ let timer;
391
+ let active = true;
392
+ const cancel = () => {
393
+ if (!active) return;
394
+ active = false; clearTimeout(timer);
395
+ card.removeEventListener('animationend', ended);
396
+ card.classList.remove('is-dismissing'); notificationClosings.delete(card);
397
+ };
398
+ const complete = () => { if (!active) return; cancel(); remove(); onDismiss?.(); };
399
+ const ended = event => { if (event.target === card && event.animationName === 'bb-ws-notification-out') complete(); };
400
+ notificationClosings.set(card, cancel); card.addEventListener('animationend', ended);
401
+ card.classList.add('is-dismissing'); timer = setTimeout(complete, 500);
402
+ return cancel;
276
403
  }
277
404
 
278
405
  /** Persistent, initially closed notification. The caller owns its lifecycle. */