@unson/brainbase-mcp 0.42.0 → 0.43.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
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",
@@ -335,6 +335,8 @@
335
335
  "./ui/personal-memory": "./ui/personal-memory.js",
336
336
  "./ui/personal-memory.css": "./ui/personal-memory.css",
337
337
  "./ui/personal-memory-http-port": "./ui/personal-memory-http-port.js",
338
+ "./ui/inbox": "./ui/inbox.js",
339
+ "./ui/inbox.css": "./ui/inbox.css",
338
340
  "./ui/area-nav": "./ui/area-nav.js",
339
341
  "./ui/area-nav.css": "./ui/area-nav.css",
340
342
  "./ui/local-web-shell": "./ui/local-web-shell.js",
@@ -494,5 +496,5 @@
494
496
  "engines": {
495
497
  "node": ">=20"
496
498
  },
497
- "gitHead": "35fc142733aac0f385f2d21e6d0651e1a9d8d7db"
499
+ "gitHead": "a8166d088899a27bb8267af9e6dd99d3550c7b63"
498
500
  }
package/ui/area-nav.css CHANGED
@@ -1,15 +1,66 @@
1
1
  /*
2
- * Area navigation (ledger D10, stage 2): the area links on the left and the
3
- * tabs of the current area above the page. Uses only the shared tokens in
4
- * brainbase-tokens.css; a host gives the area links its own look through
2
+ * Area navigation (ledger D10): the destinations on the left, grouped under
3
+ * small headings with 設定 at the bottom, and the tabs of a destination with
4
+ * several screens above the page. Uses only the shared tokens in
5
+ * brainbase-tokens.css; a host gives the links its own look through
5
6
  * `linkClassName`.
6
7
  */
7
8
  .bb-area-nav {
9
+ flex: 1 1 auto;
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: 2px;
13
+ }
14
+
15
+ .bb-area-section,
16
+ .bb-area-section-links {
8
17
  display: grid;
9
18
  gap: 2px;
10
19
  }
11
20
 
21
+ /* Space between the groups; the footer group sits at the bottom of the nav. */
22
+ .bb-area-section + .bb-area-section {
23
+ margin-top: var(--bb-space-4);
24
+ }
25
+
26
+ .bb-area-section.is-footer {
27
+ margin-top: auto;
28
+ padding-top: var(--bb-space-4);
29
+ }
30
+
31
+ .bb-area-section-label {
32
+ padding: 0 10px 4px;
33
+ color: var(--bb-color-rail-muted, var(--bb-color-muted));
34
+ font-size: 11px;
35
+ font-weight: 700;
36
+ letter-spacing: .04em;
37
+ }
38
+
39
+ .bb-area-section-link {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 4px;
43
+ border-radius: 6px;
44
+ padding-top: 4px;
45
+ text-decoration: none;
46
+ }
47
+
48
+ /* A heading that opens a screen says so with a chevron (not part of its name). */
49
+ .bb-area-section-link::after {
50
+ content: "›";
51
+ font-size: 13px;
52
+ line-height: 1;
53
+ }
54
+
55
+ .bb-area-section-link:hover,
56
+ .bb-area-section-link[aria-current="page"] {
57
+ color: var(--bb-color-accent);
58
+ }
59
+
12
60
  .bb-area-link[hidden],
61
+ .bb-area-section[hidden],
62
+ .bb-area-section-label[hidden],
63
+ .bb-area-section-links[hidden],
13
64
  .bb-area-tabs[hidden] {
14
65
  display: none;
15
66
  }
@@ -67,7 +118,8 @@
67
118
  }
68
119
 
69
120
  .bb-area-tab:focus-visible,
70
- .bb-area-link:focus-visible {
121
+ .bb-area-link:focus-visible,
122
+ .bb-area-section-link:focus-visible {
71
123
  outline: 2px solid var(--bb-color-accent);
72
124
  outline-offset: -2px;
73
125
  }
package/ui/area-nav.js CHANGED
@@ -1,13 +1,18 @@
1
1
  /*
2
2
  * Area navigation shared by OSS Personal and the organization edition
3
- * (brainbase-project ledger D10, stage 2).
3
+ * (brainbase-project ledger D10).
4
4
  *
5
- * The left side lists only the areas; the screens of the current area are tabs
6
- * above the page. An area link opens the screen last used in that area, which
7
- * is remembered in the viewer's browser only (a display preference, not a fact
8
- * of the organization). Every area link and tab is a link to a screen, so
9
- * screen addresses never change. The part draws links only: showing and
10
- * loading screens stays with the caller (`show` in, `onSelect` out).
5
+ * Each line on the left is an area: usually one screen (a destination itself,
6
+ * 2026-10-17 revision), or several screens shown as tabs above the page (設定).
7
+ * An area link opens the screen last used in that area, which is remembered in
8
+ * the viewer's browser only (a display preference, not a fact of the
9
+ * organization). Optional `sections` group the lines under small headings and
10
+ * put some at the bottom; a heading can open a screen of its own, and the
11
+ * caller can put links at the top of a section (starred projects). Without
12
+ * `sections` the lines sit directly in the nav, as in stage 2. Every link is a
13
+ * link to a screen, so screen addresses never change. The part draws links
14
+ * only: showing and loading screens stays with the caller (`show` in,
15
+ * `onSelect` / `onSelectLink` out).
11
16
  */
12
17
 
13
18
  export const AREA_NAV_CONTRACT_VERSION = 'brainbase.area-nav.v1';
@@ -78,12 +83,18 @@ export function resolveAreaTarget(area, { remembered = null, isShown = () => tru
78
83
  /**
79
84
  * @param {object} options
80
85
  * @param {Document} options.document
81
- * @param {Array<{id: string, label: string|null, defaultScreen?: string, screenIds: string[]}>} options.areas
86
+ * @param {Array<{id: string, label: string|null, defaultScreen?: string, screenIds: string[], section?: string}>} options.areas
82
87
  * In menu order. A null label takes the label of the area's only screen.
83
88
  * @param {Array<{id: string, label: string, href?: string, external?: boolean, hidden?: boolean}>} options.screens
89
+ * @param {Array<{id: string, label: string|null, placement?: 'main'|'footer', screenId?: string}>} [options.sections]
90
+ * Groups of lines. A section sits where its first area is (a section without
91
+ * areas after the other main ones); `footer` sections come last. `screenId`
92
+ * makes the heading a link to that screen. Areas without a section are grouped
93
+ * with their neighbours, without a heading.
84
94
  * @param {{get(areaId: string): string|null, set(areaId: string, screenId: string): void}} [options.memory]
85
95
  * @param {(screenId: string) => string} [options.hrefFor] Address of a screen (default `#<id>`).
86
96
  * @param {(screenId: string, event: Event) => void} [options.onSelect]
97
+ * @param {(linkId: string, event: Event) => void} [options.onSelectLink] A link given by `setLinks`.
87
98
  * @param {(area: object) => Node|null} [options.iconFor] Icon in front of an area link.
88
99
  * @param {string} [options.linkClassName] Extra class for the area links (the host's look).
89
100
  */
@@ -91,9 +102,11 @@ export function createAreaNav({
91
102
  document: doc,
92
103
  areas: areaInput,
93
104
  screens: screenInput,
105
+ sections: sectionInput,
94
106
  memory = createAreaMemory(null),
95
107
  hrefFor = (screenId) => `#${screenId}`,
96
108
  onSelect,
109
+ onSelectLink,
97
110
  iconFor,
98
111
  linkClassName = '',
99
112
  label = '画面',
@@ -108,10 +121,18 @@ export function createAreaNav({
108
121
  count: null,
109
122
  waiting: false,
110
123
  }]));
124
+ const sections = new Map((sectionInput ?? []).map((section) => [section.id, {
125
+ id: section.id,
126
+ label: section.label ?? null,
127
+ footer: section.placement === 'footer',
128
+ screenId: section.screenId ?? null,
129
+ links: [],
130
+ }]));
111
131
  const areas = (areaInput ?? []).map((area) => ({
112
132
  id: area.id,
113
133
  label: area.label ?? null,
114
134
  defaultScreen: area.defaultScreen ?? null,
135
+ section: sections.has(area.section) ? area.section : null,
115
136
  screens: area.screenIds.map((id) => screens.get(id)).filter(Boolean),
116
137
  })).filter((area) => area.screens.length > 0);
117
138
  const areaByScreen = new Map();
@@ -137,10 +158,11 @@ export function createAreaNav({
137
158
  const tabs = makeElement(doc, 'nav', { className: 'bb-area-tabs' });
138
159
  tabs.hidden = true;
139
160
  const areaLinks = new Map();
161
+ const linkClass = ['bb-area-link', linkClassName].filter(Boolean).join(' ');
140
162
 
141
163
  for (const area of areas) {
142
164
  const link = makeElement(doc, 'a', {
143
- className: ['bb-area-link', linkClassName].filter(Boolean).join(' '),
165
+ className: linkClass,
144
166
  attrs: { 'data-area': area.id },
145
167
  });
146
168
  link.addEventListener('click', (event) => {
@@ -148,7 +170,87 @@ export function createAreaNav({
148
170
  if (target) onSelect?.(target, event);
149
171
  });
150
172
  areaLinks.set(area.id, link);
151
- nav.append(link);
173
+ }
174
+
175
+ // Groups of lines: one per section, plus runs of areas without a section.
176
+ const groups = [];
177
+ if (sections.size === 0) {
178
+ nav.append(...areas.map((area) => areaLinks.get(area.id)));
179
+ } else {
180
+ const groupOf = new Map();
181
+ const makeGroup = (section) => {
182
+ const element = makeElement(doc, 'div', {
183
+ className: section?.footer ? 'bb-area-section is-footer' : 'bb-area-section',
184
+ attrs: {
185
+ role: 'group',
186
+ 'aria-label': section?.label ?? null,
187
+ 'data-section': section?.id ?? null,
188
+ 'data-placement': section?.footer ? 'footer' : null,
189
+ },
190
+ });
191
+ const group = { section, element, headingText: null, headingLink: null, linkSlot: null, areas: [] };
192
+ if (section) {
193
+ // Both forms of the heading are made once; render shows one of them.
194
+ group.headingText = makeElement(doc, 'div', { className: 'bb-area-section-label', text: section.label ?? '' });
195
+ group.headingLink = makeElement(doc, 'a', { className: 'bb-area-section-label bb-area-section-link', text: section.label ?? '' });
196
+ group.headingLink.addEventListener('click', (event) => {
197
+ if (section.screenId) onSelect?.(section.screenId, event);
198
+ });
199
+ group.linkSlot = makeElement(doc, 'div', { className: 'bb-area-section-links' });
200
+ groupOf.set(section.id, group);
201
+ }
202
+ groups.push(group);
203
+ return group;
204
+ };
205
+ for (const area of areas) {
206
+ const section = area.section ? sections.get(area.section) : null;
207
+ const last = groups.at(-1);
208
+ const group = section
209
+ ? groupOf.get(section.id) ?? makeGroup(section)
210
+ : last && !last.section ? last : makeGroup(null);
211
+ group.areas.push(area);
212
+ }
213
+ for (const section of sections.values()) if (!groupOf.has(section.id)) makeGroup(section);
214
+ const ordered = [...groups.filter((group) => !group.section?.footer), ...groups.filter((group) => group.section?.footer)];
215
+ groups.splice(0, groups.length, ...ordered);
216
+ for (const group of groups) {
217
+ group.element.append(...[group.headingText, group.headingLink, group.linkSlot, ...group.areas.map((area) => areaLinks.get(area.id))].filter(Boolean));
218
+ nav.append(group.element);
219
+ }
220
+ }
221
+
222
+ function renderHeading(group) {
223
+ const { section, headingText, headingLink } = group;
224
+ const screen = section.screenId ? screens.get(section.screenId) : null;
225
+ const opens = Boolean(screen && isShown(screen.id) && !screen.external);
226
+ headingText.hidden = !section.label || opens;
227
+ headingLink.hidden = !section.label || !opens;
228
+ if (opens) headingLink.setAttribute('href', screenHref(screen));
229
+ else headingLink.removeAttribute('href');
230
+ if (opens && activeScreenId === screen.id) headingLink.setAttribute('aria-current', 'page');
231
+ else headingLink.removeAttribute('aria-current');
232
+ return opens;
233
+ }
234
+
235
+ function renderLinks(group) {
236
+ group.linkSlot.replaceChildren(...group.section.links.map((entry) => {
237
+ const link = makeElement(doc, 'a', {
238
+ className: linkClass,
239
+ attrs: { href: entry.href, 'data-link': entry.id, 'aria-current': entry.current ? 'page' : null },
240
+ });
241
+ link.append(makeElement(doc, 'span', { className: 'bb-area-link-label', text: entry.label }));
242
+ if (entry.count > 0) link.append(countBadge(entry.count, `待っているもの${entry.count}件`));
243
+ link.addEventListener('click', (event) => onSelectLink?.(entry.id, event));
244
+ return link;
245
+ }));
246
+ group.linkSlot.hidden = group.section.links.length === 0;
247
+ }
248
+
249
+ function renderGroup(group) {
250
+ const headingOpens = group.section ? renderHeading(group) : false;
251
+ if (group.section) renderLinks(group);
252
+ const anyArea = group.areas.some((area) => !areaLinks.get(area.id).hidden);
253
+ group.element.hidden = !(anyArea || headingOpens || (group.section?.links.length ?? 0) > 0);
152
254
  }
153
255
 
154
256
  function renderAreaLink(area) {
@@ -202,6 +304,7 @@ export function createAreaNav({
202
304
 
203
305
  function render() {
204
306
  for (const area of areas) renderAreaLink(area);
307
+ for (const group of groups) renderGroup(group);
205
308
  renderTabs();
206
309
  }
207
310
 
@@ -241,6 +344,23 @@ export function createAreaNav({
241
344
  render();
242
345
  return controller;
243
346
  },
347
+ /**
348
+ * Links at the top of a section, before its areas (for example starred
349
+ * projects): `{id, label, href, count?, current?}`. An empty list removes them.
350
+ */
351
+ setLinks(sectionId, links) {
352
+ const section = sections.get(sectionId);
353
+ if (!section) return controller;
354
+ section.links = (links ?? []).filter((entry) => entry && entry.id && entry.label && entry.href).map((entry) => ({
355
+ id: String(entry.id),
356
+ label: String(entry.label),
357
+ href: String(entry.href),
358
+ count: Number.isInteger(entry.count) && entry.count > 0 ? entry.count : 0,
359
+ current: entry.current === true,
360
+ }));
361
+ render();
362
+ return controller;
363
+ },
244
364
  };
245
365
  render();
246
366
  return controller;
package/ui/inbox.css ADDED
@@ -0,0 +1,189 @@
1
+ /*
2
+ * 受信箱. Load after workspace-kit.css.
3
+ * The two tabs, the sections by what to do, the rows with their buttons on
4
+ * the right, and the lines for kinds that could not be read or are not
5
+ * collected yet. A kind that could not be read is told by words in the
6
+ * warning tone, never left out. Uses only the shared tokens in
7
+ * brainbase-tokens.css.
8
+ */
9
+ .bb-inbox-tabs {
10
+ display: flex;
11
+ gap: var(--bb-space-1);
12
+ margin: 0 0 var(--bb-space-4);
13
+ border-bottom: 1px solid var(--bb-color-border);
14
+ }
15
+
16
+ .bb-inbox-tab {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ gap: 6px;
20
+ margin-bottom: -1px;
21
+ border: 0;
22
+ border-bottom: 2px solid transparent;
23
+ padding: 8px 12px;
24
+ background: transparent;
25
+ color: var(--bb-color-muted);
26
+ font: inherit;
27
+ font-size: 14px;
28
+ cursor: pointer;
29
+ }
30
+
31
+ .bb-inbox-tab.is-selected {
32
+ border-bottom-color: var(--bb-color-accent);
33
+ color: var(--bb-color-ink);
34
+ font-weight: 600;
35
+ }
36
+
37
+ .bb-inbox-count {
38
+ min-width: 20px;
39
+ border-radius: 999px;
40
+ padding: 0 6px;
41
+ background: var(--bb-color-accent-soft);
42
+ color: var(--bb-color-accent);
43
+ font-size: 11px;
44
+ font-weight: 700;
45
+ line-height: 18px;
46
+ text-align: center;
47
+ }
48
+
49
+ .bb-inbox-loading {
50
+ height: 2px;
51
+ margin: 0 0 10px;
52
+ overflow: hidden;
53
+ background: var(--bb-color-accent-soft);
54
+ color: transparent;
55
+ font-size: 0;
56
+ }
57
+
58
+ .bb-inbox-group {
59
+ margin: 0 0 var(--bb-space-5);
60
+ }
61
+
62
+ .bb-inbox-group-title {
63
+ margin: 0 0 var(--bb-space-2);
64
+ color: var(--bb-color-muted);
65
+ font-size: 12px;
66
+ font-weight: 600;
67
+ }
68
+
69
+ .bb-inbox-list {
70
+ border: 1px solid var(--bb-color-border);
71
+ border-radius: var(--bb-radius-md);
72
+ background: var(--bb-color-paper);
73
+ }
74
+
75
+ .bb-inbox-row {
76
+ display: flex;
77
+ flex-wrap: wrap;
78
+ align-items: center;
79
+ gap: var(--bb-space-2) var(--bb-space-3);
80
+ padding: var(--bb-space-3) var(--bb-space-4);
81
+ border-top: 1px solid var(--bb-color-border);
82
+ }
83
+
84
+ .bb-inbox-row:first-child {
85
+ border-top: 0;
86
+ }
87
+
88
+ .bb-inbox-row-main {
89
+ flex: 1 1 260px;
90
+ min-width: 0;
91
+ }
92
+
93
+ .bb-inbox-title {
94
+ color: var(--bb-color-ink);
95
+ font-size: 14px;
96
+ font-weight: 600;
97
+ text-decoration: none;
98
+ overflow-wrap: anywhere;
99
+ }
100
+
101
+ a.bb-inbox-title:hover,
102
+ a.bb-inbox-title:focus-visible {
103
+ color: var(--bb-color-accent-strong);
104
+ text-decoration: underline;
105
+ }
106
+
107
+ .bb-inbox-detail {
108
+ margin: 2px 0 0;
109
+ color: var(--bb-color-muted);
110
+ font-size: 13px;
111
+ }
112
+
113
+ .bb-inbox-meta {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ gap: var(--bb-space-2);
117
+ margin-top: 4px;
118
+ color: var(--bb-color-muted);
119
+ font-size: 12px;
120
+ }
121
+
122
+ .bb-inbox-blocking {
123
+ border-radius: 999px;
124
+ padding: 0 8px;
125
+ background: var(--bb-color-warning-soft);
126
+ color: var(--bb-color-warning);
127
+ font-weight: 600;
128
+ }
129
+
130
+ .bb-inbox-due {
131
+ color: var(--bb-color-ink);
132
+ }
133
+
134
+ .bb-inbox-row-actions {
135
+ display: flex;
136
+ flex-wrap: wrap;
137
+ gap: var(--bb-space-1);
138
+ }
139
+
140
+ .bb-inbox-later-menu {
141
+ display: flex;
142
+ flex-wrap: wrap;
143
+ flex-basis: 100%;
144
+ justify-content: flex-end;
145
+ gap: var(--bb-space-1);
146
+ }
147
+
148
+ .bb-inbox-source {
149
+ margin: var(--bb-space-2) 0 0;
150
+ font-size: 13px;
151
+ }
152
+
153
+ .bb-inbox-source.is-unavailable {
154
+ border-radius: var(--bb-radius-sm);
155
+ padding: var(--bb-space-2) var(--bb-space-3);
156
+ background: var(--bb-color-warning-soft);
157
+ color: var(--bb-color-warning);
158
+ }
159
+
160
+ .bb-inbox-source.is-not-connected,
161
+ .bb-inbox-uncollected {
162
+ color: var(--bb-color-muted);
163
+ }
164
+
165
+ .bb-inbox-uncollected {
166
+ margin: 0 0 var(--bb-space-3);
167
+ font-size: 13px;
168
+ }
169
+
170
+ .bb-inbox-empty {
171
+ margin: 0 0 var(--bb-space-3);
172
+ color: var(--bb-color-ink);
173
+ font-size: 14px;
174
+ }
175
+
176
+ .bb-inbox-elsewhere {
177
+ display: flex;
178
+ gap: var(--bb-space-2);
179
+ }
180
+
181
+ @media (max-width: 640px) {
182
+ .bb-inbox-row {
183
+ padding: var(--bb-space-3);
184
+ }
185
+
186
+ .bb-inbox-row-actions {
187
+ width: 100%;
188
+ }
189
+ }
package/ui/inbox.js ADDED
@@ -0,0 +1,578 @@
1
+ /*
2
+ * 受信箱 (story-inbox-stage1-v1, docs/specs/inbox-stage1.md).
3
+ *
4
+ * What is waiting for the viewer — to answer, approve, sort or take in, and
5
+ * tasks — on one screen with two tabs, 対応待ち and お知らせ. The part never
6
+ * reads anything by itself: the host passes a port (`readInbox`,
7
+ * `setDisposition`, optional `runAction` and `readNotices`). Each kind is
8
+ * read on its own; a kind that could not be read says so and is never shown
9
+ * as zero. あとで and 片付けた belong to the host (the inbox is the canonical
10
+ * place for them), and a row's buttons call the host's own operation: the part
11
+ * has no write path of its own.
12
+ *
13
+ * The address keeps only the tab and the list being shown; titles, details
14
+ * and item ids never enter it.
15
+ */
16
+
17
+ import {
18
+ makeWorkspaceElement,
19
+ workspaceButton,
20
+ workspaceErrorCard,
21
+ workspaceNotice,
22
+ workspacePageHeader,
23
+ } from './workspace-kit.js';
24
+
25
+ export const INBOX_UI_CONTRACT_VERSION = 'brainbase.inbox-ui.v1';
26
+
27
+ export const INBOX_GROUP_LABELS = Object.freeze({
28
+ answer: '答える・決める',
29
+ approve: '承認する',
30
+ triage: '振り分ける・取り込む',
31
+ task: 'タスク',
32
+ });
33
+ const GROUPS = Object.keys(INBOX_GROUP_LABELS);
34
+ const TABS = ['waiting', 'notices'];
35
+ const VIEWS = ['waiting', 'later', 'cleared'];
36
+
37
+ const el = makeWorkspaceElement;
38
+
39
+ // Why a read failed, in the viewer's words; the code stays beside it for support.
40
+ const REASON_LABELS = Object.freeze({
41
+ upstream_unavailable: '元の記録を読めません',
42
+ forbidden: '読む権限がありません',
43
+ unauthorized: 'ログインし直してください',
44
+ store_unavailable: '受信箱の状態を保存できません',
45
+ request_failed: '通信に失敗しました',
46
+ response_invalid: '応答の形が正しくありません',
47
+ });
48
+
49
+ function reasonText(reason) {
50
+ const code = String(reason ?? 'unknown');
51
+ return REASON_LABELS[code] ? `${REASON_LABELS[code]}(${code})` : code;
52
+ }
53
+
54
+ function textOf(value) {
55
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
56
+ }
57
+
58
+ function timeOf(value) {
59
+ const time = value ? new Date(value).getTime() : Number.NaN;
60
+ return Number.isNaN(time) ? null : time;
61
+ }
62
+
63
+ function unitsOf(item) {
64
+ return Number.isInteger(item?.units) && item.units >= 0 ? item.units : 1;
65
+ }
66
+
67
+ function normalizeRoute(route = {}) {
68
+ return {
69
+ tab: TABS.includes(route.tab) ? route.tab : 'waiting',
70
+ view: VIEWS.includes(route.view) ? route.view : 'waiting',
71
+ };
72
+ }
73
+
74
+ /** Reads `tab` and `view` from the address. Unknown values fall back to 対応待ち. */
75
+ export function parseInboxRoute(params) {
76
+ return normalizeRoute({ tab: params.get('tab'), view: params.get('view') });
77
+ }
78
+
79
+ /** The shell's hash target for a route. Anything else in the route is dropped. */
80
+ export function inboxRouteTarget(route) {
81
+ const normalized = normalizeRoute(route);
82
+ const params = new URLSearchParams();
83
+ if (normalized.tab !== 'waiting') params.set('tab', normalized.tab);
84
+ else if (normalized.view !== 'waiting') params.set('view', normalized.view);
85
+ const query = params.toString();
86
+ return query ? `inbox?${query}` : 'inbox';
87
+ }
88
+
89
+ /** Blocking first, then the nearest due date (none last), then the newest arrival. */
90
+ export function sortInboxItems(items) {
91
+ return [...(Array.isArray(items) ? items : [])].sort((left, right) => {
92
+ const blocking = Number(right.blocking === true) - Number(left.blocking === true);
93
+ if (blocking !== 0) return blocking;
94
+ const leftDue = timeOf(left.dueAt);
95
+ const rightDue = timeOf(right.dueAt);
96
+ if (leftDue !== rightDue) {
97
+ if (leftDue === null) return 1;
98
+ if (rightDue === null) return -1;
99
+ return leftDue - rightDue;
100
+ }
101
+ return (timeOf(right.receivedAt) ?? 0) - (timeOf(left.receivedAt) ?? 0);
102
+ });
103
+ }
104
+
105
+ /** The disposition that still applies to an item now: same revision, and a later not yet passed. */
106
+ function activeDisposition(item, dispositions, now) {
107
+ const disposition = dispositions.get(item.id);
108
+ if (!disposition) return null;
109
+ if ((disposition.revision ?? null) !== (item.revision ?? null)) return null;
110
+ if (disposition.state === 'later') {
111
+ const until = timeOf(disposition.until);
112
+ return until !== null && until > now.getTime() ? disposition : null;
113
+ }
114
+ return disposition.state === 'cleared' ? disposition : null;
115
+ }
116
+
117
+ function dispositionMap(list) {
118
+ const map = new Map();
119
+ for (const disposition of Array.isArray(list) ? list : []) {
120
+ if (textOf(disposition?.itemId)) map.set(disposition.itemId, disposition);
121
+ }
122
+ return map;
123
+ }
124
+
125
+ function readableItems(result) {
126
+ const readable = new Set((result.sources ?? []).filter((source) => source.status === 'ok').map((source) => source.kind));
127
+ return (Array.isArray(result.items) ? result.items : []).filter((item) => readable.has(item.kind) && GROUPS.includes(item.group));
128
+ }
129
+
130
+ /**
131
+ * The 対応待ち count for the left menu: what is shown (hidden and cleared rows
132
+ * left out, a summary row counts as its own number), from the kinds that could
133
+ * be read. `count` is null when the inbox itself could not be read.
134
+ */
135
+ export function countInboxWaiting(result, now = new Date()) {
136
+ if (result?.status !== 'ok') return { count: null, unreadable: [] };
137
+ const dispositions = dispositionMap(result.dispositions);
138
+ const count = readableItems(result)
139
+ .filter((item) => !activeDisposition(item, dispositions, now))
140
+ .reduce((sum, item) => sum + unitsOf(item), 0);
141
+ const unreadable = (result.sources ?? []).filter((source) => source.status === 'unavailable').map((source) => source.label);
142
+ return { count, unreadable };
143
+ }
144
+
145
+ function at(base, dayOffset, hour) {
146
+ const date = new Date(base.getFullYear(), base.getMonth(), base.getDate() + dayOffset, hour, 0, 0, 0);
147
+ return date;
148
+ }
149
+
150
+ /** あとで: this evening (only before 18:00), tomorrow morning, next Monday morning — by the viewer's clock. */
151
+ export function inboxLaterChoices(now = new Date()) {
152
+ const choices = [];
153
+ const evening = at(now, 0, 18);
154
+ if (evening.getTime() > now.getTime()) choices.push({ id: 'evening', label: '今日の夕方', until: evening });
155
+ choices.push({ id: 'tomorrow', label: '明日の朝', until: at(now, 1, 9) });
156
+ const toMonday = ((8 - now.getDay()) % 7) || 7;
157
+ choices.push({ id: 'monday', label: '来週の月曜の朝', until: at(now, toMonday, 9) });
158
+ return choices;
159
+ }
160
+
161
+ export const INBOX_DISPOSITIONS_KEY = 'brainbase.inbox.dispositions.v1';
162
+ const CLEARED_KEPT_MS = 30 * 86_400_000;
163
+
164
+ /**
165
+ * 片付けた・あとで kept in the viewer's browser (stage 1: the inbox has no
166
+ * canonical store yet; stage 4 moves them there with the Slack sync). One
167
+ * key per person, because a browser can be shared by several accounts. A
168
+ * storage that cannot be reached reads as nothing kept and fails every write
169
+ * with `store_unavailable`, so the screen puts the row back.
170
+ */
171
+ export function createLocalInboxDispositions(storage, { personId = null, key = INBOX_DISPOSITIONS_KEY, now = () => new Date() } = {}) {
172
+ const storageKey = textOf(personId) ? `${key}:${personId}` : key;
173
+ const read = () => {
174
+ try {
175
+ const raw = storage?.getItem?.(storageKey);
176
+ const value = raw ? JSON.parse(raw) : [];
177
+ return Array.isArray(value) ? value.filter((entry) => textOf(entry?.itemId)) : [];
178
+ } catch {
179
+ return [];
180
+ }
181
+ };
182
+ const current = (entries) => {
183
+ const time = now().getTime();
184
+ // A passed あとで and an old 片付けた are dropped so the key does not grow.
185
+ return entries.filter((entry) => (entry.state === 'later'
186
+ ? (timeOf(entry.until) ?? 0) > time
187
+ : entry.state === 'cleared' && time - (timeOf(entry.at) ?? 0) < CLEARED_KEPT_MS));
188
+ };
189
+ return Object.freeze({
190
+ list: () => current(read()),
191
+ set(itemId, change) {
192
+ if (!storage || typeof storage.setItem !== 'function') return { status: 'unavailable', reason: 'store_unavailable' };
193
+ const others = current(read()).filter((entry) => entry.itemId !== itemId);
194
+ const next = change?.state === 'later' || change?.state === 'cleared'
195
+ ? [...others, { itemId, revision: change.revision ?? null, state: change.state, until: change.state === 'later' ? change.until : null, at: now().toISOString() }]
196
+ : others;
197
+ try {
198
+ storage.setItem(storageKey, JSON.stringify(next));
199
+ return { status: 'ok' };
200
+ } catch {
201
+ return { status: 'unavailable', reason: 'store_unavailable' };
202
+ }
203
+ },
204
+ });
205
+ }
206
+
207
+ /** A port whose 片付けた・あとで live in `store` (see createLocalInboxDispositions). */
208
+ export function withLocalInboxDispositions(reader, store) {
209
+ const port = {
210
+ async readInbox() {
211
+ const result = await reader.readInbox();
212
+ return result?.status === 'ok' ? { ...result, dispositions: store.list() } : result;
213
+ },
214
+ async setDisposition(itemId, change) {
215
+ return store.set(itemId, change);
216
+ },
217
+ };
218
+ if (typeof reader.runAction === 'function') port.runAction = (itemId, actionId) => reader.runAction(itemId, actionId);
219
+ if (typeof reader.readNotices === 'function') port.readNotices = () => reader.readNotices();
220
+ return port;
221
+ }
222
+
223
+ function formatShort(value) {
224
+ const time = timeOf(value);
225
+ if (time === null) return null;
226
+ const date = new Date(time);
227
+ return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
228
+ }
229
+
230
+ function relativeTime(value, now) {
231
+ const time = timeOf(value);
232
+ if (time === null) return null;
233
+ const minutes = Math.floor((now.getTime() - time) / 60_000);
234
+ if (minutes < 1) return 'たった今';
235
+ if (minutes < 60) return `${minutes}分前`;
236
+ const hours = Math.floor(minutes / 60);
237
+ if (hours < 24) return `${hours}時間前`;
238
+ const days = Math.floor(hours / 24);
239
+ return days < 30 ? `${days}日前` : formatShort(value);
240
+ }
241
+
242
+ export function createInboxUI({
243
+ root,
244
+ page,
245
+ document: explicitDocument,
246
+ port,
247
+ route: initialRoute,
248
+ onRouteChange,
249
+ onNavigate,
250
+ onCount,
251
+ now = () => new Date(),
252
+ } = {}) {
253
+ if (!root) throw new TypeError('root is required');
254
+ if (!port || typeof port.readInbox !== 'function' || typeof port.setDisposition !== 'function') {
255
+ throw new TypeError('port is required');
256
+ }
257
+ const doc = explicitDocument ?? (typeof document === 'undefined' ? null : document);
258
+ if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
259
+ const crumbs = Array.isArray(page?.crumbs) ? page.crumbs : ['受信箱'];
260
+ const readNow = () => (typeof now === 'function' ? now() : now);
261
+
262
+ const state = {
263
+ route: normalizeRoute(initialRoute),
264
+ phase: 'idle',
265
+ result: null,
266
+ reason: null,
267
+ dispositions: new Map(),
268
+ laterOpen: null,
269
+ notice: null,
270
+ notices: null,
271
+ destroyed: false,
272
+ };
273
+ let generation = 0;
274
+
275
+ function go(route) {
276
+ const next = normalizeRoute({ ...state.route, ...route });
277
+ if (typeof onRouteChange === 'function') onRouteChange(next);
278
+ else applyRoute(next);
279
+ }
280
+
281
+ function reportCount() {
282
+ if (typeof onCount !== 'function') return;
283
+ onCount(state.result ? countInboxWaiting({ ...state.result, dispositions: [...state.dispositions.values()] }, readNow()) : { count: null, unreadable: [] });
284
+ }
285
+
286
+ async function loadNotices() {
287
+ if (typeof port.readNotices !== 'function') {
288
+ state.notices = { phase: 'not_collected' };
289
+ return;
290
+ }
291
+ let result;
292
+ try {
293
+ result = await port.readNotices();
294
+ } catch (error) {
295
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
296
+ }
297
+ state.notices = result?.status === 'ok' && Array.isArray(result.items)
298
+ ? { phase: 'ok', items: result.items }
299
+ : { phase: 'unavailable', reason: result?.reason ?? 'response_invalid' };
300
+ }
301
+
302
+ async function load() {
303
+ if (state.destroyed) return;
304
+ const current = ++generation;
305
+ // The previous rows stay on screen while the next read runs.
306
+ state.phase = 'loading';
307
+ render();
308
+ let result;
309
+ try {
310
+ result = await port.readInbox();
311
+ } catch (error) {
312
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
313
+ }
314
+ if (state.route.tab === 'notices' && state.notices === null) await loadNotices();
315
+ if (current !== generation || state.destroyed) return;
316
+ if (result?.status === 'ok' && Array.isArray(result.sources) && Array.isArray(result.items)) {
317
+ state.result = result;
318
+ state.dispositions = dispositionMap(result.dispositions);
319
+ state.phase = 'ready';
320
+ state.reason = null;
321
+ } else {
322
+ state.result = null;
323
+ state.phase = 'error';
324
+ state.reason = result?.reason ?? 'response_invalid';
325
+ }
326
+ render();
327
+ reportCount();
328
+ }
329
+
330
+ function applyRoute(route) {
331
+ state.route = normalizeRoute(route);
332
+ state.laterOpen = null;
333
+ if (state.route.tab === 'notices' && state.notices === null) {
334
+ void loadNotices().then(() => render());
335
+ }
336
+ render();
337
+ }
338
+
339
+ async function dispose(item, change, failureTitle) {
340
+ const previous = state.dispositions.get(item.id) ?? null;
341
+ if (change.state === 'open') state.dispositions.delete(item.id);
342
+ else state.dispositions.set(item.id, { itemId: item.id, revision: change.revision, state: change.state, until: change.until ?? null, at: readNow().toISOString() });
343
+ state.laterOpen = null;
344
+ state.notice = null;
345
+ render();
346
+ reportCount();
347
+ let result;
348
+ try {
349
+ result = await port.setDisposition(item.id, change);
350
+ } catch (error) {
351
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
352
+ }
353
+ if (state.destroyed || result?.status === 'ok') return;
354
+ if (previous) state.dispositions.set(item.id, previous);
355
+ else state.dispositions.delete(item.id);
356
+ state.notice = `${failureTitle}。理由: ${reasonText(result?.reason ?? 'response_invalid')}。`;
357
+ render();
358
+ reportCount();
359
+ }
360
+
361
+ async function runAction(item, action) {
362
+ let result;
363
+ try {
364
+ result = await port.runAction(item.id, action.id);
365
+ } catch (error) {
366
+ result = { status: 'unavailable', reason: error instanceof Error ? error.message : 'request_failed' };
367
+ }
368
+ if (state.destroyed) return;
369
+ if (result?.status === 'ok') {
370
+ state.notice = null;
371
+ await load();
372
+ return;
373
+ }
374
+ state.notice = `「${action.label}」を実行できませんでした。理由: ${reasonText(result?.reason ?? 'response_invalid')}。`;
375
+ render();
376
+ }
377
+
378
+ function openItem(item, event) {
379
+ if (item.clearOnOpen === true) void dispose(item, { state: 'cleared', revision: item.revision ?? null }, '開いた行を片付けられませんでした');
380
+ const href = textOf(item.href);
381
+ if (href && typeof onNavigate === 'function') {
382
+ event?.preventDefault?.();
383
+ onNavigate(href);
384
+ }
385
+ }
386
+
387
+ function renderTabs(waitingCount) {
388
+ const bar = el(doc, 'div', { className: 'bb-inbox-tabs', attrs: { role: 'tablist', 'aria-label': '受信箱' } });
389
+ for (const [id, label] of [['waiting', '対応待ち'], ['notices', 'お知らせ']]) {
390
+ const selected = state.route.tab === id;
391
+ const tab = el(doc, 'button', {
392
+ className: `bb-inbox-tab${selected ? ' is-selected' : ''}`,
393
+ text: label,
394
+ attrs: { type: 'button', role: 'tab', 'aria-selected': selected ? 'true' : 'false' },
395
+ });
396
+ if (id === 'waiting' && Number.isInteger(waitingCount) && waitingCount > 0) {
397
+ tab.append(el(doc, 'span', { className: 'bb-inbox-count', text: waitingCount, attrs: { 'aria-label': `${waitingCount}件` } }));
398
+ }
399
+ tab.addEventListener('click', () => go({ tab: id, view: 'waiting' }));
400
+ bar.append(tab);
401
+ }
402
+ return bar;
403
+ }
404
+
405
+ function renderLaterMenu(item) {
406
+ const menu = el(doc, 'div', { className: 'bb-inbox-later-menu', attrs: { role: 'group', 'aria-label': 'あとでの時刻' } });
407
+ for (const choice of inboxLaterChoices(readNow())) {
408
+ menu.append(workspaceButton(doc, {
409
+ text: choice.label,
410
+ variant: 'quiet',
411
+ onClick: () => void dispose(item, { state: 'later', until: choice.until.toISOString(), revision: item.revision ?? null }, 'あとでにできませんでした'),
412
+ }));
413
+ }
414
+ return menu;
415
+ }
416
+
417
+ function renderRow(item, view) {
418
+ const row = el(doc, 'div', { className: `bb-inbox-row${item.blocking === true ? ' is-blocking' : ''}`, attrs: { role: 'listitem', 'data-inbox-item': item.id } });
419
+ const main = el(doc, 'div', { className: 'bb-inbox-row-main' });
420
+ const href = textOf(item.href);
421
+ const title = el(doc, href ? 'a' : 'span', { className: 'bb-inbox-title', text: item.title, attrs: href ? { href } : {} });
422
+ if (href) title.addEventListener('click', (event) => openItem(item, event));
423
+ main.append(title);
424
+ const meta = el(doc, 'div', { className: 'bb-inbox-meta' });
425
+ if (item.blocking === true) meta.append(el(doc, 'span', { className: 'bb-inbox-blocking', text: '止めています' }));
426
+ const due = formatShort(item.dueAt);
427
+ if (due) meta.append(el(doc, 'span', { className: 'bb-inbox-due', text: `期限 ${due}` }));
428
+ const received = relativeTime(item.receivedAt, readNow());
429
+ if (received) meta.append(el(doc, 'span', { text: received, attrs: { title: formatShort(item.receivedAt) ?? undefined } }));
430
+ const disposition = state.dispositions.get(item.id);
431
+ if (view === 'later' && disposition?.until) meta.append(el(doc, 'span', { className: 'bb-inbox-until', text: `${formatShort(disposition.until)}に戻ります` }));
432
+ if (textOf(item.detail)) main.append(el(doc, 'p', { className: 'bb-inbox-detail', text: item.detail }));
433
+ if (meta.children?.length !== 0) main.append(meta);
434
+ row.append(main);
435
+
436
+ const controls = el(doc, 'div', { className: 'bb-inbox-row-actions' });
437
+ if (view === 'waiting') {
438
+ if (typeof port.runAction === 'function') {
439
+ for (const action of Array.isArray(item.actions) ? item.actions : []) {
440
+ if (!textOf(action?.id) || !textOf(action?.label)) continue;
441
+ controls.append(workspaceButton(doc, { text: action.label, variant: action.tone === 'primary' ? 'primary' : action.tone === 'danger' ? 'danger' : 'default', onClick: () => void runAction(item, action) }));
442
+ }
443
+ }
444
+ controls.append(
445
+ workspaceButton(doc, {
446
+ text: 'あとで',
447
+ variant: 'quiet',
448
+ attrs: { 'aria-expanded': state.laterOpen === item.id ? 'true' : 'false', 'aria-haspopup': 'true' },
449
+ onClick: () => {
450
+ state.laterOpen = state.laterOpen === item.id ? null : item.id;
451
+ render();
452
+ },
453
+ }),
454
+ workspaceButton(doc, { text: '片付けた', variant: 'quiet', onClick: () => void dispose(item, { state: 'cleared', revision: item.revision ?? null }, '片付けられませんでした') }),
455
+ );
456
+ } else {
457
+ controls.append(workspaceButton(doc, { text: '対応待ちに戻す', variant: 'quiet', onClick: () => void dispose(item, { state: 'open' }, '対応待ちに戻せませんでした') }));
458
+ }
459
+ row.append(controls);
460
+ if (state.laterOpen === item.id) row.append(renderLaterMenu(item));
461
+ return row;
462
+ }
463
+
464
+ function sourceLine(source) {
465
+ if (source.status === 'not_connected') {
466
+ return el(doc, 'p', { className: 'bb-inbox-source is-not-connected', text: `${source.label}は未接続です。` });
467
+ }
468
+ return el(doc, 'p', { className: 'bb-inbox-source is-unavailable', text: `${source.label}を読めませんでした。理由: ${reasonText(source.reason)}。0件ではありません。`, attrs: { role: 'status' } });
469
+ }
470
+
471
+ function partition(now) {
472
+ const shown = { waiting: [], later: [], cleared: [] };
473
+ for (const item of readableItems(state.result)) {
474
+ const disposition = activeDisposition(item, state.dispositions, now);
475
+ shown[disposition ? disposition.state : 'waiting'].push(item);
476
+ }
477
+ return shown;
478
+ }
479
+
480
+ function renderWaiting(shown) {
481
+ const view = el(doc, 'div', { className: 'bb-inbox-waiting' });
482
+ const sources = state.result.sources;
483
+ let anyContent = false;
484
+ for (const group of GROUPS) {
485
+ const rows = sortInboxItems(shown.waiting.filter((item) => item.group === group));
486
+ const problems = sources.filter((source) => source.group === group && source.status !== 'ok');
487
+ if (rows.length === 0 && problems.length === 0) continue;
488
+ anyContent = true;
489
+ const section = el(doc, 'section', { className: 'bb-inbox-group', attrs: { 'data-group': group, 'aria-label': INBOX_GROUP_LABELS[group] } });
490
+ section.append(el(doc, 'h2', { className: 'bb-inbox-group-title', text: INBOX_GROUP_LABELS[group] }));
491
+ if (rows.length > 0) {
492
+ const list = el(doc, 'div', { className: 'bb-inbox-list', attrs: { role: 'list' } });
493
+ for (const item of rows) list.append(renderRow(item, 'waiting'));
494
+ section.append(list);
495
+ }
496
+ for (const source of problems) section.append(sourceLine(source));
497
+ view.append(section);
498
+ }
499
+ const allRead = sources.every((source) => source.status !== 'unavailable');
500
+ if (!anyContent && allRead) view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: '待っているものはありません。' }));
501
+ const uncollected = (Array.isArray(state.result.uncollected) ? state.result.uncollected : []).filter(textOf);
502
+ if (uncollected.length > 0) {
503
+ view.append(el(doc, 'p', { className: 'bb-inbox-uncollected', text: `まだ集めていない種類があります(${uncollected.join('・')})。件数には入っていません。` }));
504
+ }
505
+ const links = el(doc, 'div', { className: 'bb-inbox-elsewhere' });
506
+ if (shown.later.length > 0) links.append(workspaceButton(doc, { text: `あとで ${shown.later.length}件`, variant: 'quiet', onClick: () => go({ view: 'later' }) }));
507
+ if (shown.cleared.length > 0) links.append(workspaceButton(doc, { text: `片付けた ${shown.cleared.length}件`, variant: 'quiet', onClick: () => go({ view: 'cleared' }) }));
508
+ if (links.children.length > 0) view.append(links);
509
+ return view;
510
+ }
511
+
512
+ function renderSet(shown, which) {
513
+ const view = el(doc, 'div', { className: 'bb-inbox-set' });
514
+ view.append(workspaceButton(doc, { text: '対応待ちへ', variant: 'quiet', onClick: () => go({ view: 'waiting' }) }));
515
+ view.append(el(doc, 'h2', { className: 'bb-inbox-group-title', text: which === 'later' ? 'あとで' : '片付けた' }));
516
+ const rows = which === 'later'
517
+ ? [...shown.later].sort((left, right) => (timeOf(state.dispositions.get(left.id)?.until) ?? 0) - (timeOf(state.dispositions.get(right.id)?.until) ?? 0))
518
+ : [...shown.cleared].sort((left, right) => (timeOf(state.dispositions.get(right.id)?.at) ?? 0) - (timeOf(state.dispositions.get(left.id)?.at) ?? 0));
519
+ if (rows.length === 0) {
520
+ view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: which === 'later' ? 'あとでにしたものはありません。' : '片付けたものはありません。' }));
521
+ return view;
522
+ }
523
+ const list = el(doc, 'div', { className: 'bb-inbox-list', attrs: { role: 'list' } });
524
+ for (const item of rows) list.append(renderRow(item, which));
525
+ view.append(list);
526
+ return view;
527
+ }
528
+
529
+ function renderNotices() {
530
+ const view = el(doc, 'div', { className: 'bb-inbox-notices' });
531
+ const notices = state.notices;
532
+ if (!notices || notices.phase === 'not_collected') {
533
+ view.append(el(doc, 'p', { className: 'bb-inbox-uncollected', text: 'お知らせはまだ集めていません。AIに任せた仕事の終わり・議事録ができたこと・申請の結果は、これから集めます。' }));
534
+ } else if (notices.phase === 'unavailable') {
535
+ view.append(workspaceErrorCard(doc, { title: 'お知らせを読めませんでした', message: `理由: ${reasonText(notices.reason)}。0件ではありません。` }));
536
+ } else if (notices.items.length === 0) {
537
+ view.append(el(doc, 'p', { className: 'bb-inbox-empty', text: 'お知らせはありません。' }));
538
+ }
539
+ return view;
540
+ }
541
+
542
+ function render() {
543
+ if (state.destroyed) return;
544
+ const current = readNow();
545
+ const shown = state.result ? partition(current) : null;
546
+ const waitingCount = shown ? shown.waiting.reduce((sum, item) => sum + unitsOf(item), 0) : null;
547
+ const view = el(doc, 'div', { className: 'bb-inbox', attrs: { 'data-contract-version': INBOX_UI_CONTRACT_VERSION } });
548
+ view.append(workspacePageHeader(doc, { crumbs, title: '受信箱', source: page?.source ?? undefined }));
549
+ view.append(renderTabs(waitingCount));
550
+ if (state.phase === 'loading') view.append(el(doc, 'div', { className: 'bb-inbox-loading', text: '読み込んでいます', attrs: { role: 'status' } }));
551
+ if (state.notice) view.append(workspaceNotice(doc, { label: 'できませんでした', text: state.notice, tone: 'warning', role: 'alert' }));
552
+ if (state.route.tab === 'notices') {
553
+ view.append(renderNotices());
554
+ } else if (state.phase === 'error' && !shown) {
555
+ view.append(workspaceErrorCard(doc, { title: '受信箱を読めませんでした', message: `理由: ${reasonText(state.reason)}。0件ではありません。`, onRetry: () => void load() }));
556
+ } else if (shown) {
557
+ view.append(state.route.view === 'waiting' ? renderWaiting(shown) : renderSet(shown, state.route.view));
558
+ }
559
+ root.replaceChildren(view);
560
+ }
561
+
562
+ const controller = {
563
+ load,
564
+ applyRoute,
565
+ get state() {
566
+ return { route: { ...state.route }, phase: state.phase, result: state.result, dispositions: [...state.dispositions.values()] };
567
+ },
568
+ destroy() {
569
+ state.destroyed = true;
570
+ generation += 1;
571
+ root.replaceChildren();
572
+ },
573
+ };
574
+ render();
575
+ return controller;
576
+ }
577
+
578
+ export default createInboxUI;
@@ -67,9 +67,11 @@ body {
67
67
  letter-spacing: -.035em;
68
68
  }
69
69
 
70
+ /* The menu fills the rail so that 設定 sits at its bottom (ledger D10). */
70
71
  .bb-shell-nav {
71
- display: grid;
72
- gap: 2px;
72
+ flex: 1 1 auto;
73
+ display: flex;
74
+ flex-direction: column;
73
75
  }
74
76
 
75
77
  .bb-shell-nav-link {
@@ -61,62 +61,63 @@ const NAV_ICON_PATHS = Object.freeze({
61
61
  ontology: ['M4 4h6v6H4z', 'M14 14h6v6h-6z', 'M14 4h6v6h-6z', 'M10 7h4', 'M17 10v4'],
62
62
  // World view (extension id `world`, ledger P17).
63
63
  world: ['M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z', 'M3 12h18', 'M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z'],
64
- // Areas whose screens have their own icons (ledger D10, stage 2): a briefcase, and
65
- // the organization edition's gear.
66
- 'area:work': ['M4 8h16v11H4z', 'M9 8V5h6v3', 'M4 13h16'],
64
+ // 設定 (ledger D10): the organization edition's gear.
67
65
  'area:settings': ['M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'],
68
66
  });
69
67
  // Screens without their own icon (for example a private product's extension).
70
68
  const DEFAULT_NAV_ICON_PATHS = Object.freeze(['M5 20V10m7 10V4m7 16v-7']);
71
69
  const SVG_NS = 'http://www.w3.org/2000/svg';
72
70
 
73
- // The menu's areas (brainbase-project ledger D10): the same order and names as
74
- // the organization edition. The left side lists only the areas; the screens of
75
- // the current area are tabs above the page (stage 2). 目的と現状 is the only
76
- // screen of its area and keeps its own name. Inside an area, `order` lists the
77
- // screens first in that order; `defaultScreen` opens when nothing is remembered.
78
- const NAV_AREAS = Object.freeze([
79
- Object.freeze({ id: 'purpose', label: null, order: ['objectives'], icon: 'objectives' }),
80
- Object.freeze({ id: 'work', label: '仕事', order: ['projects', 'minutes'], defaultScreen: 'projects', icon: 'area:work' }),
81
- Object.freeze({ id: 'outcomes', label: '成果', order: ['value-proofs'], icon: 'value-proofs' }),
82
- Object.freeze({ id: 'information', label: '情報と関係', order: ['graph', 'people', 'memory'], defaultScreen: 'graph', icon: 'graph' }),
83
- Object.freeze({ id: 'settings', label: '設定', order: ['ontology'], defaultScreen: 'ontology', icon: 'area:settings' }),
71
+ // The menu's sections (brainbase-project ledger D10, 2026-10-17 revision): the
72
+ // destinations themselves under small headings, in the organization edition's
73
+ // order, with 設定 at the bottom. Screens OSS Personal does not have are not
74
+ // listed. `screenId` makes the heading open that screen (プロジェクトと関係者);
75
+ // `area` turns the section's screens into one line with tabs (設定). Inside a
76
+ // section, `order` lists the screens first in that order.
77
+ const NAV_SECTIONS = Object.freeze([
78
+ Object.freeze({ id: 'meetings', label: '会議', order: ['minutes'] }),
79
+ Object.freeze({ id: 'project', label: 'プロジェクト', screenId: 'projects', order: ['objectives'] }),
80
+ Object.freeze({ id: 'explore', label: '調べる', order: ['value-proofs', 'graph', 'people', 'memory'] }),
81
+ Object.freeze({ id: 'settings', label: null, placement: 'footer', order: ['ontology'], area: Object.freeze({ label: '設定', defaultScreen: 'ontology', icon: 'area:settings' }) }),
84
82
  ]);
85
83
 
84
+ const sectionOf = (screen) => NAV_SECTIONS.find((section) => section.id === screen.section) ?? null;
85
+
86
86
  // Menu order only: the screens' own order (and so the first screen) stays as given.
87
- // Screens without an area keep their place — those before the first area-bound
88
- // screen come first (a leading extension such as the world), the rest come last.
89
- function navLayout(screens) {
90
- const firstInArea = screens.findIndex((screen) => NAV_AREAS.some((area) => area.id === screen.area));
91
- if (firstInArea === -1) return { leading: [...screens], areas: [], trailing: [] };
92
- const leading = screens.slice(0, firstInArea);
93
- const trailing = screens.slice(firstInArea).filter((screen) => !NAV_AREAS.some((area) => area.id === screen.area));
94
- const areas = NAV_AREAS.map((area) => {
95
- const members = screens.filter((screen) => screen.area === area.id);
87
+ // Screens without a section keep their place — those before the first sectioned
88
+ // screen come first (a leading extension such as the world), the rest come after
89
+ // the sections and before 設定.
90
+ function navAreas(screens) {
91
+ const own = (screen, section = null) => ({ id: screen.id, label: null, screenIds: [screen.id], icon: screen.id, ...(section ? { section } : {}) });
92
+ const firstSectioned = screens.findIndex((screen) => sectionOf(screen));
93
+ const leading = firstSectioned === -1 ? [...screens] : screens.slice(0, firstSectioned);
94
+ const trailing = firstSectioned === -1 ? [] : screens.slice(firstSectioned).filter((screen) => !sectionOf(screen));
95
+ const sectioned = NAV_SECTIONS.map((section) => {
96
+ const members = screens.filter((screen) => screen.section === section.id && screen.id !== section.screenId);
96
97
  const rank = (screen) => {
97
- const index = area.order.indexOf(screen.id);
98
- return index === -1 ? area.order.length + members.indexOf(screen) : index;
98
+ const index = section.order.indexOf(screen.id);
99
+ return index === -1 ? section.order.length + members.indexOf(screen) : index;
99
100
  };
100
- return { area, screens: [...members].sort((a, b) => rank(a) - rank(b)) };
101
- }).filter((entry) => entry.screens.length > 0);
102
- return { leading, areas, trailing };
101
+ const ordered = [...members].sort((a, b) => rank(a) - rank(b));
102
+ if (ordered.length === 0) return { section, areas: [] };
103
+ if (section.area) {
104
+ return { section, areas: [{ id: section.id, label: section.area.label, defaultScreen: section.area.defaultScreen, screenIds: ordered.map((screen) => screen.id), icon: section.area.icon, section: section.id }] };
105
+ }
106
+ return { section, areas: ordered.map((screen) => own(screen, section.id)) };
107
+ });
108
+ const main = sectioned.filter(({ section }) => section.placement !== 'footer').flatMap(({ areas }) => areas);
109
+ const footer = sectioned.filter(({ section }) => section.placement === 'footer').flatMap(({ areas }) => areas);
110
+ return [...leading.map((screen) => own(screen)), ...main, ...trailing.map((screen) => own(screen)), ...footer];
103
111
  }
104
112
 
105
- // Every menu line is an area: a screen without an area is an area of its own.
106
- function navAreas(screens) {
107
- const layout = navLayout(screens);
108
- const own = (screen) => ({ id: screen.id, label: null, screenIds: [screen.id], icon: screen.id });
109
- return [
110
- ...layout.leading.map(own),
111
- ...layout.areas.map(({ area, screens: members }) => ({
112
- id: area.id,
113
- label: area.label,
114
- defaultScreen: area.defaultScreen,
115
- screenIds: members.map((screen) => screen.id),
116
- icon: area.icon,
117
- })),
118
- ...layout.trailing.map(own),
119
- ];
113
+ // The sections handed to the menu part; a heading opens its screen only when that screen exists.
114
+ function navSections(screens) {
115
+ return NAV_SECTIONS.map((section) => ({
116
+ id: section.id,
117
+ label: section.label,
118
+ placement: section.placement ?? 'main',
119
+ ...(section.screenId && screens.some((screen) => screen.id === section.screenId) ? { screenId: section.screenId } : {}),
120
+ }));
120
121
  }
121
122
 
122
123
  function makeIcon(doc, id) {
@@ -376,19 +377,19 @@ function mountGraphScreen(screenId, createView, extra = {}) {
376
377
  }
377
378
 
378
379
  export const LOCAL_WEB_SCREENS = Object.freeze([
379
- Object.freeze({ id: 'value-proofs', label: '判断の履歴', area: 'outcomes', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
380
- Object.freeze({ id: 'minutes', label: '議事録', area: 'work', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
381
- Object.freeze({ id: 'objectives', label: '目的と現状', area: 'purpose', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
382
- Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', area: 'work', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
380
+ Object.freeze({ id: 'value-proofs', label: '判断の履歴', section: 'explore', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
381
+ Object.freeze({ id: 'minutes', label: '議事録', section: 'meetings', usesGraph: false, rail: true, source: '正本の議事録', mount: mountMeetingMinutes }),
382
+ Object.freeze({ id: 'objectives', label: '目的と現状', section: 'project', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
383
+ Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', section: 'project', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
383
384
  // A record of 情報と関係 opens as its own screen at `#graph?entity_id=…`; the list is `#graph`.
384
- Object.freeze({ id: 'graph', label: '情報と関係', area: 'information', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView, (context) => ({
385
+ Object.freeze({ id: 'graph', label: '情報と関係', section: 'explore', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView, (context) => ({
385
386
  onRouteChange: (entityId, options) => context.navigate?.(entityId ? `graph?entity_id=${encodeURIComponent(entityId)}` : 'graph', options),
386
387
  })) }),
387
- Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
388
+ Object.freeze({ id: 'people', label: '人と会社', section: 'explore', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
388
389
  // Only the owner sees it (ledger D15); the local host has one owner, so it is always listed here.
389
- Object.freeze({ id: 'memory', label: 'あなたの記憶', area: 'information', usesGraph: false, rail: true, ownerOnly: true, source: '手元の個人KG', mount: mountPersonalMemory }),
390
+ Object.freeze({ id: 'memory', label: 'あなたの記憶', section: 'explore', usesGraph: false, rail: true, ownerOnly: true, source: '手元の個人KG', mount: mountPersonalMemory }),
390
391
  // The definitions behind 情報と関係: infrequent management work, so under settings (ledger D14).
391
- Object.freeze({ id: 'ontology', label: '情報の種類とつながり方', area: 'settings', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('ontology', createGraphOntologyView, { canCorrect: true }) }),
392
+ Object.freeze({ id: 'ontology', label: '情報の種類とつながり方', section: 'settings', usesGraph: true, rail: false, source: '手元のGraph', mount: mountGraphScreen('ontology', createGraphOntologyView, { canCorrect: true }) }),
392
393
  ]);
393
394
 
394
395
  function renderGraphGate(doc, slot, status, onRecheck, label) {
@@ -516,6 +517,7 @@ export function createLocalWebShell({
516
517
  const areaNav = createAreaNav({
517
518
  document: doc,
518
519
  areas: areaList,
520
+ sections: navSections(screens),
519
521
  screens: screens.map((screen) => ({ id: screen.id, label: screen.label })),
520
522
  memory: areaMemory,
521
523
  linkClassName: 'bb-shell-nav-link',