@unson/brainbase-mcp 0.41.13 → 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/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
+ }