@unson/brainbase-mcp 0.41.3 → 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.
@@ -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. */