@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 +4 -2
- package/ui/area-nav.css +56 -4
- package/ui/area-nav.js +130 -10
- package/ui/inbox.css +189 -0
- package/ui/inbox.js +578 -0
- package/ui/local-web-shell.css +4 -2
- package/ui/local-web-shell.js +53 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "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": "
|
|
499
|
+
"gitHead": "a8166d088899a27bb8267af9e6dd99d3550c7b63"
|
|
498
500
|
}
|
package/ui/area-nav.css
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Area navigation (ledger D10
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
3
|
+
* (brainbase-project ledger D10).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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:
|
|
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
|
-
|
|
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;
|
package/ui/local-web-shell.css
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
72
|
+
flex: 1 1 auto;
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
.bb-shell-nav-link {
|
package/ui/local-web-shell.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Object.freeze({ id: '
|
|
81
|
-
Object.freeze({ id: '
|
|
82
|
-
Object.freeze({ id: '
|
|
83
|
-
Object.freeze({ id: 'settings', label: '
|
|
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
|
|
88
|
-
// screen come first (a leading extension such as the world), the rest come
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
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 =
|
|
98
|
-
return index === -1 ?
|
|
98
|
+
const index = section.order.indexOf(screen.id);
|
|
99
|
+
return index === -1 ? section.order.length + members.indexOf(screen) : index;
|
|
99
100
|
};
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
...
|
|
112
|
-
|
|
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: '判断の履歴',
|
|
380
|
-
Object.freeze({ id: 'minutes', label: '議事録',
|
|
381
|
-
Object.freeze({ id: 'objectives', label: '目的と現状',
|
|
382
|
-
Object.freeze({ id: 'projects', label: 'プロジェクトと関係者',
|
|
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: '情報と関係',
|
|
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: '人と会社',
|
|
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: 'あなたの記憶',
|
|
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: '情報の種類とつながり方',
|
|
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',
|