@unson/brainbase-mcp 0.16.0 → 0.17.1

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.
@@ -11,11 +11,13 @@ export declare const GRAPH_CORRECTION_SCHEMA: "brainbase-graph-correction.v1";
11
11
  /** Relative to the data directory. Committed as a sidecar of the canonical SSOT transaction. */
12
12
  export declare const GRAPH_CORRECTIONS_FILE = "evidence/graph-corrections.jsonl";
13
13
  export declare const GRAPH_CORRECTION_ENTITY_FIELDS: readonly ["name", "aliases", "summary", "validFrom", "validTo"];
14
- export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status"];
14
+ export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status", "icon"];
15
15
  export declare const GRAPH_CORRECTION_EDGE_FIELDS: readonly ["role", "context", "validTo"];
16
16
  export declare const GRAPH_CORRECTION_NEW_EDGE_RELATIONS: readonly ["participates_in", "accountable_for", "member_of"];
17
17
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
18
18
  export declare const GRAPH_CORRECTION_APPLIES_TO: readonly ["search", "get_context", "resolve_entity"];
19
+ /** Decoded bytes, shared with the browser-side project-icon helper. */
20
+ export declare const PROJECT_ICON_MAX_BYTES: number;
19
21
  export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge';
20
22
  export type GraphNewEdgeRelation = typeof GRAPH_CORRECTION_NEW_EDGE_RELATIONS[number];
21
23
  export interface GraphEntityCorrectionChanges {
@@ -28,6 +30,8 @@ export interface GraphEntityCorrectionChanges {
28
30
  goal?: string | null;
29
31
  /** Projects only. Stored as `metadata.status`. */
30
32
  status?: string | null;
33
+ /** Projects only. Stored as `metadata.icon` as a bounded PNG/JPEG/WebP data URL. */
34
+ icon?: string | null;
31
35
  }
32
36
  export interface GraphEdgeCorrectionChanges {
33
37
  role?: string | null;
@@ -155,3 +159,5 @@ export declare function readGraphCorrectionHistory(dataDir: string): Promise<{
155
159
  records: GraphCorrectionRecord[];
156
160
  issues: GraphCorrectionHistoryIssue[];
157
161
  }>;
162
+ /** Returns true only for a bounded, signature-checked project icon data URL. */
163
+ export declare function isProjectIconDataUrl(value: unknown): value is string;
@@ -18,7 +18,7 @@ export const GRAPH_CORRECTION_SCHEMA = 'brainbase-graph-correction.v1';
18
18
  /** Relative to the data directory. Committed as a sidecar of the canonical SSOT transaction. */
19
19
  export const GRAPH_CORRECTIONS_FILE = 'evidence/graph-corrections.jsonl';
20
20
  export const GRAPH_CORRECTION_ENTITY_FIELDS = ['name', 'aliases', 'summary', 'validFrom', 'validTo'];
21
- export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status'];
21
+ export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status', 'icon'];
22
22
  export const GRAPH_CORRECTION_EDGE_FIELDS = ['role', 'context', 'validTo'];
23
23
  export const GRAPH_CORRECTION_NEW_EDGE_RELATIONS = ['participates_in', 'accountable_for', 'member_of'];
24
24
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
@@ -27,6 +27,8 @@ const MAX_REASON_LENGTH = 500;
27
27
  const MAX_NAME_LENGTH = 200;
28
28
  const MAX_TEXT_LENGTH = 2000;
29
29
  const MAX_ALIASES = 50;
30
+ /** Decoded bytes, shared with the browser-side project-icon helper. */
31
+ export const PROJECT_ICON_MAX_BYTES = 256 * 1024;
30
32
  const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
31
33
  const PROJECTED_RELATIONS = new Set(['participates_in', 'accountable_for']);
32
34
  export class GraphCorrectionError extends Error {
@@ -213,8 +215,8 @@ function planEntityUpdate(graph, relationships, correction, at) {
213
215
  const before = matches[0];
214
216
  assertExpectedDigest('entity', before, correction.expectedDigest);
215
217
  const changes = correction.changes;
216
- if ((changes.goal !== undefined || changes.status !== undefined) && before.type !== 'project') {
217
- invalid('correction_field_not_allowed', 'goal and status can be corrected only on a project');
218
+ if ((changes.goal !== undefined || changes.status !== undefined || changes.icon !== undefined) && before.type !== 'project') {
219
+ invalid('correction_field_not_allowed', 'goal, status and icon can be corrected only on a project');
218
220
  }
219
221
  const after = structuredClone(before);
220
222
  const diff = {};
@@ -228,7 +230,7 @@ function planEntityUpdate(graph, relationships, correction, at) {
228
230
  setField(after, 'validFrom', changes.validFrom, before, diff);
229
231
  if (changes.validTo !== undefined)
230
232
  setField(after, 'validTo', changes.validTo, before, diff);
231
- if (changes.goal !== undefined || changes.status !== undefined) {
233
+ if (changes.goal !== undefined || changes.status !== undefined || changes.icon !== undefined) {
232
234
  const metadata = { ...(before.metadata ?? {}) };
233
235
  for (const key of ['goal', 'status']) {
234
236
  const next = changes[key];
@@ -243,6 +245,18 @@ function planEntityUpdate(graph, relationships, correction, at) {
243
245
  metadata[key] = next;
244
246
  diff[`metadata.${key}`] = { before: previous, after: next };
245
247
  }
248
+ if (changes.icon !== undefined) {
249
+ const previous = typeof metadata.icon === 'string' ? metadata.icon : null;
250
+ const next = changes.icon;
251
+ if (previous !== next) {
252
+ if (next === null)
253
+ delete metadata.icon;
254
+ else
255
+ metadata.icon = next;
256
+ // Keep image bytes and the long data URL out of append-only history.
257
+ diff['metadata.icon'] = { before: projectIconSummary(previous), after: projectIconSummary(next) };
258
+ }
259
+ }
246
260
  if (Object.keys(metadata).length > 0)
247
261
  after.metadata = metadata;
248
262
  else
@@ -488,6 +502,8 @@ function parseEntityChanges(value) {
488
502
  changes.goal = optionalText(value.goal, 'changes.goal', MAX_TEXT_LENGTH);
489
503
  if (value.status !== undefined)
490
504
  changes.status = optionalText(value.status, 'changes.status', MAX_NAME_LENGTH);
505
+ if (value.icon !== undefined)
506
+ changes.icon = parseProjectIcon(value.icon);
491
507
  return changes;
492
508
  }
493
509
  function parseEdgeChanges(value) {
@@ -577,6 +593,75 @@ function optionalDateTime(value, field) {
577
593
  }
578
594
  return value;
579
595
  }
596
+ const PROJECT_ICON_DATA_URL = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})(?![\s\S])/u;
597
+ function parseProjectIcon(value) {
598
+ if (value === null)
599
+ return null;
600
+ if (typeof value !== 'string')
601
+ invalid('correction_invalid', 'changes.icon must be a PNG, JPEG, WebP data URL or null');
602
+ const match = PROJECT_ICON_DATA_URL.exec(value);
603
+ if (!match || value.length === 0)
604
+ invalid('correction_invalid', 'changes.icon must be a PNG, JPEG, WebP data URL or null');
605
+ const payload = match[2];
606
+ if (!payload || payload.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(payload)) {
607
+ invalid('correction_invalid', 'changes.icon must contain canonical base64 data');
608
+ }
609
+ let decoded;
610
+ try {
611
+ decoded = Buffer.from(payload, 'base64');
612
+ }
613
+ catch {
614
+ invalid('correction_invalid', 'changes.icon contains invalid base64 data');
615
+ }
616
+ if (decoded.length === 0 || decoded.toString('base64') !== payload) {
617
+ invalid('correction_invalid', 'changes.icon contains invalid base64 data');
618
+ }
619
+ if (decoded.length > PROJECT_ICON_MAX_BYTES) {
620
+ invalid('correction_invalid', `changes.icon must be at most ${PROJECT_ICON_MAX_BYTES} decoded bytes`);
621
+ }
622
+ const mimeType = match[1];
623
+ const validSignature = mimeType === 'image/png'
624
+ ? decoded.length >= 8 && Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).equals(decoded.subarray(0, 8))
625
+ : mimeType === 'image/jpeg'
626
+ ? decoded.length >= 4 && decoded[0] === 0xff && decoded[1] === 0xd8 && decoded[2] === 0xff
627
+ && decoded[decoded.length - 2] === 0xff && decoded[decoded.length - 1] === 0xd9
628
+ : decoded.length >= 12 && decoded.subarray(0, 4).toString('ascii') === 'RIFF'
629
+ && decoded.subarray(8, 12).toString('ascii') === 'WEBP';
630
+ if (!validSignature)
631
+ invalid('correction_invalid', 'changes.icon image signature does not match its MIME type');
632
+ return value;
633
+ }
634
+ /** Returns true only for a bounded, signature-checked project icon data URL. */
635
+ export function isProjectIconDataUrl(value) {
636
+ if (value === null)
637
+ return false;
638
+ try {
639
+ return parseProjectIcon(value) !== null;
640
+ }
641
+ catch (error) {
642
+ if (error instanceof GraphCorrectionError && error.kind === 'invalid')
643
+ return false;
644
+ throw error;
645
+ }
646
+ }
647
+ function projectIconSummary(value) {
648
+ if (value === null || value === undefined)
649
+ return null;
650
+ if (typeof value !== 'string')
651
+ return { present: true, valid: false };
652
+ const match = PROJECT_ICON_DATA_URL.exec(value);
653
+ if (!match)
654
+ return { present: true, valid: false };
655
+ if (!isProjectIconDataUrl(value))
656
+ return { present: true, valid: false };
657
+ try {
658
+ const decoded = Buffer.from(match[2], 'base64');
659
+ return { present: true, valid: true, mimeType: match[1], bytes: decoded.length };
660
+ }
661
+ catch {
662
+ return { present: true, valid: false };
663
+ }
664
+ }
580
665
  function requireId(value, field) {
581
666
  if (typeof value !== 'string' || value.trim() === '' || value.length > 500)
582
667
  invalid('correction_invalid', `${field} must be a non-empty id`);
@@ -2,7 +2,9 @@ import { applyGraphCorrection, GraphCorrectionError } from './graph-corrections.
2
2
  import { GraphWebError, listGraphProjects, readGraphEntity, readGraphOntology, readGraphProject, readGraphWebStatus, searchGraphEntities } from './graph-web.js';
3
3
  export const GRAPH_WEB_HTTP_VERSION = 'graph-web-http.v1';
4
4
  const DEFAULT_BASE_PATH = '/api/graph';
5
- const MAX_BODY_BYTES = 16 * 1024;
5
+ // A 256KiB decoded image becomes roughly 350KiB as base64 in JSON. Keep a
6
+ // bounded request envelope while allowing the project-icon correction through.
7
+ const MAX_BODY_BYTES = 512 * 1024;
6
8
  class HttpError extends Error {
7
9
  statusCode;
8
10
  code;
@@ -74,6 +74,8 @@ export interface GraphEntityView {
74
74
  digest: string;
75
75
  goal?: string | null;
76
76
  status?: string | null;
77
+ /** Projects only. A validated PNG/JPEG/WebP data URL, or null when unset. */
78
+ icon?: string | null;
77
79
  }
78
80
  export type GraphProvenanceReference = {
79
81
  status: 'none';
package/dist/graph-web.js CHANGED
@@ -2,7 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isActiveAt } from './canonical-graph.js';
4
4
  import { assertFoundationCatalog, findFoundationRecord } from './foundation-catalog.js';
5
- import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, readGraphCorrectionHistory } from './graph-corrections.js';
5
+ import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
6
6
  import { ONTOLOGY_VERSION, portableOntology } from './ontology.js';
7
7
  import { canonicalRelationRegistry, getCanonicalRelation } from './relation-registry.js';
8
8
  import { loadPersonalOs } from './ssot.js';
@@ -534,11 +534,23 @@ function entityView(entity, asOf) {
534
534
  validTo: entity.validTo ?? null,
535
535
  active: isActiveAt(entity, asOf),
536
536
  digest: graphRecordDigest(entity),
537
- ...(entity.type === 'project' ? { goal: metadataString(entity, 'goal'), status: metadataString(entity, 'status') } : {})
537
+ ...(entity.type === 'project'
538
+ ? { goal: metadataString(entity, 'goal'), status: metadataString(entity, 'status'), icon: projectIconString(entity) }
539
+ : {})
538
540
  };
539
541
  }
540
542
  function projectView(project, asOf) {
541
- return { ...entityView(project, asOf), type: 'project', goal: metadataString(project, 'goal'), status: metadataString(project, 'status') };
543
+ return {
544
+ ...entityView(project, asOf),
545
+ type: 'project',
546
+ goal: metadataString(project, 'goal'),
547
+ status: metadataString(project, 'status'),
548
+ icon: projectIconString(project),
549
+ };
550
+ }
551
+ function projectIconString(entity) {
552
+ const value = entity.metadata?.icon;
553
+ return isProjectIconDataUrl(value) ? value : null;
542
554
  }
543
555
  function historyFor(records, ids) {
544
556
  return records.filter((record) => ids.has(record.target.id)).reverse();
@@ -683,6 +683,7 @@ export function createGraphWebModule(context) {
683
683
  return {
684
684
  id: 'graph',
685
685
  uiFiles: [
686
+ 'project-icon.js',
686
687
  'graph-view-shared.js',
687
688
  'graph-view-shared.css',
688
689
  'graph-projects-view.js',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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",
@@ -228,6 +228,7 @@
228
228
  "./ui/workspace-kit.css": "./ui/workspace-kit.css",
229
229
  "./ui/graph-view-shared": "./ui/graph-view-shared.js",
230
230
  "./ui/graph-view-shared.css": "./ui/graph-view-shared.css",
231
+ "./ui/project-icon": "./ui/project-icon.js",
231
232
  "./ui/graph-own-share": "./ui/graph-own-share.js",
232
233
  "./ui/graph-projects-view": "./ui/graph-projects-view.js",
233
234
  "./ui/graph-projects-view.css": "./ui/graph-projects-view.css",
@@ -358,5 +359,5 @@
358
359
  "engines": {
359
360
  "node": ">=20"
360
361
  },
361
- "gitHead": "a0914d025914d4ada329b730aeddd6488f28bcfd"
362
+ "gitHead": "e7f165e9a2dd0ebbcb0a561160f927afbad95b95"
362
363
  }
@@ -5,7 +5,8 @@
5
5
  * Built on the organization edition's screen pattern (workspace kit): the
6
6
  * workspace has the page head, the source notice, summary metrics and the
7
7
  * project ledger; the right rail has the selected project — its outline, each
8
- * 関係者 and the corrections. The first project is selected on load.
8
+ * 関係者 and the corrections. The first project is selected on load when the
9
+ * host has not provided a selection.
9
10
  *
10
11
  * Reads `GET {base}/projects` and `GET {base}/projects/:id` and corrects
11
12
  * through `POST {base}/corrections` (see `graph-view-shared.js`). 「関係者」
@@ -55,6 +56,7 @@ import {
55
56
  graphHostEmptyNotice,
56
57
  } from './graph-view-shared.js';
57
58
  import { graphOwnShareSource, ownProjectDetail, ownShareSection, ownShareStateNotice } from './graph-own-share.js';
59
+ import { renderProjectIcon } from './project-icon.js';
58
60
  import {
59
61
  createProjectKnowledgeWorkspace,
60
62
  normalizeProjectContext,
@@ -208,8 +210,9 @@ function normalizeProjectSummary(value) {
208
210
  * history). Called synchronously on every rail render while a project is selected, with the read
209
211
  * project detail, or null while it loads or when it could not be read. A throw or a value that is
210
212
  * not a node is shown as a small danger notice instead of breaking the rail.
211
- * @param {string} [options.selectedId] The project to select first. When absent or not in the
212
- * list, the first project is selected, as by default.
213
+ * @param {string} [options.selectedId] The project to select first. When it
214
+ * is absent, the first project is selected. When it is not in the list, the
215
+ * requested id stays selected and the rail shows not found.
213
216
  * @param {(id: string) => void} [options.onSelect] Called when the view changes the selection itself
214
217
  * (a ledger row, or the automatic first project). Not called for `select(id)`.
215
218
  * @param {{ basePath: string, fetcher?: Function, label?: string }} [options.ownShare]
@@ -617,7 +620,7 @@ export function createGraphProjectsView({
617
620
  void reading;
618
621
  },
619
622
  cells: [
620
- { primary: project.name, secondary: project.id },
623
+ { primary: project.name, secondary: project.id, leading: renderProjectIcon(doc, project.icon, project.name, { size: 'sm' }) },
621
624
  textCell(project.goal, '目的未記入'),
622
625
  accountableCell(project),
623
626
  textCell(project.status, '未記入'),
@@ -721,7 +724,7 @@ export function createGraphProjectsView({
721
724
  const project = loaded?.project ?? listItem(state.selectedId) ?? { id: state.selectedId, name: state.selectedId };
722
725
  // The record a host's read-only note may point at.
723
726
  const noteSubject = { id: project.id, type: 'project', metadata: loaded?.project?.metadata ?? {} };
724
- const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id })];
727
+ const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id, leading: renderProjectIcon(doc, project.icon, project.name) })];
725
728
  if (!loaded) {
726
729
  const readState = detail?.id === state.selectedId
727
730
  ? detail
@@ -756,7 +759,7 @@ export function createGraphProjectsView({
756
759
  children.push(renderParticipants(loaded));
757
760
  if (writable) {
758
761
  const canAdd = scope.createEdges;
759
- const projectFields = scope.entity('project') ? scope.fields(['name', 'goal', 'status']) : [];
762
+ const projectFields = scope.entity('project') ? scope.fields(['name', 'goal', 'status', 'icon']) : [];
760
763
  const actions = [
761
764
  canAdd ? workspaceButton(doc, {
762
765
  text: '関係者を加える',
@@ -809,7 +812,7 @@ export function createGraphProjectsView({
809
812
  return state.list;
810
813
  }
811
814
  const { projects } = state.list.payload;
812
- if (!state.selectedId || !projects.some((project) => project.id === state.selectedId)) {
815
+ if (!state.selectedId) {
813
816
  if (correction.form) correction.close();
814
817
  state.panelAt = null;
815
818
  state.detail = null;
@@ -824,7 +827,22 @@ export function createGraphProjectsView({
824
827
  if (viewDestroyed || request !== hostListRequest) return state.list;
825
828
  return state.list;
826
829
  }
827
- } else if (state.detail?.id !== state.selectedId) {
830
+ } else if (!projects.some((project) => project.id === state.selectedId)) {
831
+ // An explicit host selection, or a project selected by the user, must
832
+ // never be replaced with the first row when the refreshed list no
833
+ // longer contains it. Keeping the id makes the missing target visible
834
+ // and lets the host decide whether its identity or access changed.
835
+ if (correction.form) correction.close();
836
+ detailRequest += 1;
837
+ state.panelAt = null;
838
+ state.detail = {
839
+ id: state.selectedId,
840
+ state: 'not_found',
841
+ reason: 'このプロジェクトは一覧から見つかりません。一覧を読み直してください。',
842
+ };
843
+ state.context = null;
844
+ contextRequest += 1;
845
+ } else if (state.detail?.id !== state.selectedId || state.detail?.state === 'not_found') {
828
846
  // The project the host asked for is listed; read it for the rail.
829
847
  if (viewDestroyed || request !== hostListRequest) return state.list;
830
848
  await controller.loadDetail(state.selectedId);
@@ -100,6 +100,55 @@
100
100
  min-width: 0;
101
101
  }
102
102
 
103
+ /* Project marks are shared by the ledger, rail and correction readback. The
104
+ unregistered initial is painted from an attribute so it never pollutes the
105
+ ledger's accessible project name or correction history with data URLs. */
106
+ .bb-project-icon {
107
+ display: inline-grid;
108
+ flex: 0 0 36px;
109
+ place-items: center;
110
+ width: 36px;
111
+ height: 36px;
112
+ overflow: hidden;
113
+ border: 1px solid var(--bb-color-border);
114
+ border-radius: var(--bb-radius-sm);
115
+ color: var(--bb-color-accent-strong);
116
+ background: var(--bb-color-accent-soft);
117
+ font-size: 15px;
118
+ font-weight: 750;
119
+ line-height: 1;
120
+ }
121
+
122
+ .bb-project-icon.is-sm {
123
+ flex-basis: 26px;
124
+ width: 26px;
125
+ height: 26px;
126
+ border-radius: 6px;
127
+ font-size: 11px;
128
+ }
129
+
130
+ .bb-project-icon::before { content: attr(data-initial); }
131
+ .bb-project-icon.is-registered::before { content: ''; }
132
+ .bb-project-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
133
+
134
+ .bb-graph-icon-value,
135
+ .bb-graph-icon-picker {
136
+ display: inline-flex;
137
+ flex-wrap: wrap;
138
+ align-items: center;
139
+ gap: var(--bb-space-2);
140
+ }
141
+
142
+ .bb-graph-icon-picker { margin-top: var(--bb-space-2); }
143
+ .bb-graph-icon-picker-state { color: var(--bb-color-muted); font-size: 12px; }
144
+ .bb-graph-field-error { display: block; color: var(--bb-color-danger); font-size: 12px; }
145
+ .bb-graph-correction .bb-project-icon { margin-right: var(--bb-space-1); }
146
+
147
+ .bb-ws-ledger-object > .bb-project-icon {
148
+ grid-row: 1 / span 2;
149
+ }
150
+ .bb-ws-rail-head > .bb-project-icon { margin-bottom: var(--bb-space-3); }
151
+
103
152
  /* Compact items: each 関係者 or relation in the rail. */
104
153
  .bb-graph-compact-list {
105
154
  display: grid;
@@ -21,6 +21,15 @@
21
21
  */
22
22
 
23
23
  import { workspaceButton, workspaceNotice } from './workspace-kit.js';
24
+ import {
25
+ PROJECT_ICON_MAX_BYTES,
26
+ PROJECT_ICON_MIME_TYPES,
27
+ projectIconMimeLabel,
28
+ projectIconSummary,
29
+ readProjectIconFile,
30
+ renderProjectIcon,
31
+ validateProjectIconDataUrl,
32
+ } from './project-icon.js';
24
33
 
25
34
  export const GRAPH_VIEW_SHARED_CONTRACT_VERSION = 'brainbase.graph-view-shared.v2';
26
35
 
@@ -76,8 +85,10 @@ const FIELD_LABELS = Object.freeze({
76
85
  validTo: '終了日',
77
86
  goal: '目的',
78
87
  status: '状態',
88
+ icon: 'アイコン',
79
89
  'metadata.goal': '目的',
80
90
  'metadata.status': '状態',
91
+ 'metadata.icon': 'アイコン',
81
92
  role: '役割',
82
93
  context: '文脈',
83
94
  fromId: '起点',
@@ -99,6 +110,7 @@ export const GRAPH_CORRECTION_FIELDS = Object.freeze({
99
110
  summary: Object.freeze({ label: '要約', control: 'textarea', max: 2000 }),
100
111
  goal: Object.freeze({ label: '目的', control: 'textarea', max: 2000 }),
101
112
  status: Object.freeze({ label: '状態', control: 'text', max: 200, help: '自由記述です。例: 進行中、保留、完了' }),
113
+ icon: Object.freeze({ label: 'アイコン', control: 'project-icon', help: 'PNG、JPEG、WebP。256KiB以下。' }),
102
114
  validFrom: Object.freeze({ label: '開始日', control: 'date' }),
103
115
  validTo: Object.freeze({ label: '終了日', control: 'date', help: 'この日の0時から、終わったものとして扱います。空にすると終了日を消します。' }),
104
116
  role: Object.freeze({ label: '役割', control: 'text', max: 200, help: '自由記述です。例: 最終判断、進行管理' }),
@@ -448,7 +460,7 @@ export function graphCorrectionScope(scope) {
448
460
 
449
461
  /** The record fields the correction form offers for an entity of this kind. */
450
462
  export function graphEntityCorrectionFields(type) {
451
- return ['name', 'aliases', 'summary', 'validFrom', 'validTo', ...(type === 'project' ? ['goal', 'status'] : [])];
463
+ return ['name', 'aliases', 'summary', 'validFrom', 'validTo', ...(type === 'project' ? ['goal', 'status', 'icon'] : [])];
452
464
  }
453
465
 
454
466
  /** Whether a built correction request stays inside the scope (`recordType` is the corrected entity's kind). */
@@ -718,6 +730,9 @@ export function graphRecordValues(record) {
718
730
  validTo: formatDay(record?.validTo) ?? '',
719
731
  goal: pick(typeof record?.goal === 'string' ? record.goal : metadata.goal),
720
732
  status: pick(typeof record?.status === 'string' && record?.type === 'project' ? record.status : metadata.status),
733
+ // Keep the raw value in form state for exact comparison; renderers below
734
+ // always replace it with a short summary and never put the data URL in text.
735
+ icon: typeof metadata.icon === 'string' ? metadata.icon : '',
721
736
  role: pick(record?.role),
722
737
  context: pick(record?.context),
723
738
  };
@@ -725,16 +740,32 @@ export function graphRecordValues(record) {
725
740
 
726
741
  function displayValue(field, value) {
727
742
  if (field === 'validFrom' || field === 'validTo') return value ? value : 'なし';
743
+ if (field === 'icon') {
744
+ const summary = projectIconSummary(value);
745
+ return summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録';
746
+ }
728
747
  return value ? value : '未記入';
729
748
  }
730
749
 
731
- function valueRows(record, fields) {
750
+ function valueRows(doc, record, fields) {
732
751
  const values = graphRecordValues(record);
733
- return fields.map((field) => [GRAPH_CORRECTION_FIELDS[field]?.label ?? FIELD_LABELS[field] ?? field, displayValue(field, values[field])]);
752
+ return fields.map((field) => [
753
+ GRAPH_CORRECTION_FIELDS[field]?.label ?? FIELD_LABELS[field] ?? field,
754
+ field === 'icon' ? renderIconValue(doc, values[field], record?.name) : displayValue(field, values[field]),
755
+ ]);
734
756
  }
735
757
 
736
758
  function renderValues(doc, record, fields, leadingRows = []) {
737
- return facts(doc, [...leadingRows, ...valueRows(record, fields)], 'bb-graph-facts');
759
+ return facts(doc, [...leadingRows, ...valueRows(doc, record, fields)], 'bb-graph-facts');
760
+ }
761
+
762
+ function renderIconValue(doc, value, name) {
763
+ const summary = projectIconSummary(value);
764
+ const wrap = makeElement(doc, 'span', { className: 'bb-graph-icon-value' });
765
+ wrap.append(renderProjectIcon(doc, value, name, { size: 'sm' }), makeElement(doc, 'span', {
766
+ text: summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録',
767
+ }));
768
+ return wrap;
738
769
  }
739
770
 
740
771
  function splitAliases(value) {
@@ -791,6 +822,15 @@ export function buildCorrectionRequest(form) {
791
822
 
792
823
  const changes = {};
793
824
  for (const field of form.fields) {
825
+ if (field === 'icon') {
826
+ const before = form.original[field] ?? '';
827
+ const after = form.draft[field] ?? '';
828
+ if (after === before) continue;
829
+ const parsed = validateProjectIconDataUrl(after === '' ? null : after);
830
+ if (!parsed.ok) return { ok: false, message: parsed.message };
831
+ changes.icon = parsed.value;
832
+ continue;
833
+ }
794
834
  const before = String(form.original[field] ?? '');
795
835
  const after = String(form.draft[field] ?? '');
796
836
  if (after.trim() === before.trim()) continue;
@@ -846,6 +886,29 @@ function fieldControl(doc, form, field, callbacks) {
846
886
  const id = `${form.formId}-${field}`;
847
887
  const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
848
888
  wrap.append(makeElement(doc, 'label', { text: definition.label, attrs: { for: id } }));
889
+ if (definition.control === 'project-icon') {
890
+ const input = makeElement(doc, 'input', { attrs: {
891
+ id,
892
+ name: field,
893
+ type: 'file',
894
+ accept: 'image/png,image/jpeg,image/webp',
895
+ } });
896
+ input.addEventListener('change', (event) => callbacks.onIconFile(event?.target?.files?.[0] ?? null));
897
+ const preview = makeElement(doc, 'div', { className: 'bb-graph-icon-picker' });
898
+ preview.append(renderProjectIcon(doc, form.draft[field], form.recordName ?? form.subject ?? 'プロジェクト', { size: 'md' }));
899
+ const summary = projectIconSummary(form.draft[field]);
900
+ preview.append(makeElement(doc, 'span', {
901
+ className: 'bb-graph-icon-picker-state',
902
+ text: summary ? `${projectIconMimeLabel(summary.mimeType)}(${Math.ceil(summary.bytes / 1024)}KiB)` : '未登録',
903
+ }));
904
+ if (form.draft[field]) {
905
+ preview.append(button(doc, 'アイコンを外す', () => callbacks.onInput(field, null), { className: 'bb-ws-button is-quiet' }));
906
+ }
907
+ wrap.append(input, preview);
908
+ if (form.fieldErrors?.[field]) wrap.append(makeElement(doc, 'small', { className: 'bb-graph-field-error', text: form.fieldErrors[field], attrs: { role: 'alert' } }));
909
+ if (definition.help) wrap.append(makeElement(doc, 'small', { className: 'bb-graph-help', text: definition.help }));
910
+ return wrap;
911
+ }
849
912
  const control = definition.control === 'textarea'
850
913
  ? makeElement(doc, 'textarea', { attrs: { id, name: field, rows: 3, maxlength: definition.max } })
851
914
  : makeElement(doc, 'input', { attrs: { id, name: field, type: definition.control === 'date' ? 'date' : 'text', maxlength: definition.max } });
@@ -1010,9 +1073,21 @@ export function renderCorrectionPanel(doc, form, callbacks) {
1010
1073
  formElement.append(reasonControl(doc, form, callbacks));
1011
1074
  const actions = makeElement(doc, 'div', { className: 'bb-graph-actions' });
1012
1075
  const saving = form.phase === 'saving';
1013
- const submitLabel = saving ? '保存しています' : form.phase === 'conflict' && form.conflictCode === 'digest_conflict' ? '今の内容に対して保存する' : '保存する';
1076
+ const iconReadPending = form.iconReadPending === true;
1077
+ const iconHasError = Boolean(form.fieldErrors?.icon);
1078
+ const submitLabel = saving
1079
+ ? '保存しています'
1080
+ : iconReadPending
1081
+ ? 'アイコンを読み込んでいます'
1082
+ : form.phase === 'conflict' && form.conflictCode === 'digest_conflict'
1083
+ ? '今の内容に対して保存する'
1084
+ : '保存する';
1014
1085
  actions.append(
1015
- makeElement(doc, 'button', { className: 'bb-ws-button is-primary', text: submitLabel, attrs: { type: 'submit', disabled: saving } }),
1086
+ makeElement(doc, 'button', {
1087
+ className: 'bb-ws-button is-primary',
1088
+ text: submitLabel,
1089
+ attrs: { type: 'submit', disabled: saving || iconReadPending || iconHasError },
1090
+ }),
1016
1091
  button(doc, '取り消す', callbacks.onClose, { attrs: { disabled: saving } }),
1017
1092
  );
1018
1093
  formElement.append(actions);
@@ -1034,18 +1109,24 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1034
1109
  let form = null;
1035
1110
  let focusPending = false;
1036
1111
  let sequence = 0;
1112
+ // A file read can finish after a newer selection, removal, cancellation or
1113
+ // form switch. Invalidate the older read before touching the draft.
1114
+ let iconReadGeneration = 0;
1037
1115
  // Label/input ids stay unique when two screens are on one page.
1038
1116
  const idPrefix = `bb-graph-form-${Math.random().toString(36).slice(2, 8)}`;
1039
1117
  const redraw = () => rerender?.();
1040
1118
 
1041
1119
  function baseForm(kind, extra) {
1042
1120
  sequence += 1;
1121
+ iconReadGeneration += 1;
1043
1122
  focusPending = true;
1044
1123
  return {
1045
1124
  kind,
1046
1125
  formId: `${idPrefix}-${sequence}`,
1047
1126
  phase: 'editing',
1048
1127
  message: null,
1128
+ fieldErrors: {},
1129
+ iconReadPending: false,
1049
1130
  reason: '',
1050
1131
  current: null,
1051
1132
  conflictCode: null,
@@ -1079,7 +1160,60 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1079
1160
 
1080
1161
  const callbacks = {
1081
1162
  onInput(field, value) {
1082
- if (form) form.draft[field] = value;
1163
+ if (form) {
1164
+ if (field === 'icon') {
1165
+ iconReadGeneration += 1;
1166
+ form.iconReadPending = false;
1167
+ form.fieldErrors.icon = null;
1168
+ form.message = null;
1169
+ }
1170
+ form.draft[field] = value;
1171
+ if (field === 'icon') redraw();
1172
+ }
1173
+ },
1174
+ async onIconFile(file) {
1175
+ const generation = ++iconReadGeneration;
1176
+ const target = form;
1177
+ if (!target || target.kind === 'create_edge') return;
1178
+ // Selecting the file input and then cancelling is a no-op; keep the
1179
+ // current draft and any already selected icon intact.
1180
+ if (!file) {
1181
+ target.iconReadPending = false;
1182
+ target.fieldErrors.icon = null;
1183
+ target.message = null;
1184
+ redraw();
1185
+ return;
1186
+ }
1187
+ target.iconReadPending = false;
1188
+ target.message = null;
1189
+ if (typeof file.size === 'number' && file.size > PROJECT_ICON_MAX_BYTES) {
1190
+ target.fieldErrors.icon = `アイコンは${Math.floor(PROJECT_ICON_MAX_BYTES / 1024)}KiB以下にしてください。`;
1191
+ redraw();
1192
+ return;
1193
+ }
1194
+ if (typeof file.type !== 'string' || !PROJECT_ICON_MIME_TYPES.includes(file.type)) {
1195
+ target.fieldErrors.icon = 'アイコンはPNG、JPEG、WebPの画像を選んでください。';
1196
+ redraw();
1197
+ return;
1198
+ }
1199
+ target.iconReadPending = true;
1200
+ target.fieldErrors.icon = null;
1201
+ target.message = null;
1202
+ redraw();
1203
+ try {
1204
+ const value = await readProjectIconFile(file);
1205
+ const parsed = validateProjectIconDataUrl(value);
1206
+ if (!parsed.ok) throw new Error(parsed.message);
1207
+ if (form !== target || iconReadGeneration !== generation) return;
1208
+ target.draft.icon = parsed.value;
1209
+ target.iconReadPending = false;
1210
+ target.message = null;
1211
+ } catch (error) {
1212
+ if (form !== target || iconReadGeneration !== generation) return;
1213
+ target.iconReadPending = false;
1214
+ target.fieldErrors.icon = error instanceof Error ? error.message : 'アイコンを読み取れません。';
1215
+ }
1216
+ if (form === target && iconReadGeneration === generation) redraw();
1083
1217
  },
1084
1218
  onReason(value) {
1085
1219
  if (form) form.reason = value;
@@ -1115,6 +1249,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1115
1249
  title,
1116
1250
  subject: subject ?? `${typeLabel(record.type)}「${record.name}」`,
1117
1251
  recordType: record.type,
1252
+ recordName: record.name,
1118
1253
  targetId: record.id,
1119
1254
  expectedDigest: record.digest,
1120
1255
  fields: shown,
@@ -1164,12 +1299,26 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1164
1299
  return form;
1165
1300
  },
1166
1301
  close() {
1302
+ iconReadGeneration += 1;
1167
1303
  form = null;
1168
1304
  redraw();
1169
1305
  },
1170
1306
  async submit() {
1171
1307
  const target = form;
1172
1308
  if (!target || target.phase === 'saving') return target;
1309
+ if (target.fieldErrors?.icon) {
1310
+ target.message = { tone: 'danger', text: target.fieldErrors.icon };
1311
+ redraw();
1312
+ return target;
1313
+ }
1314
+ if (target.iconReadPending === true) {
1315
+ target.message = {
1316
+ tone: 'warning',
1317
+ text: 'アイコンを読み込んでいます。読み込みが終わってから保存してください。',
1318
+ };
1319
+ redraw();
1320
+ return target;
1321
+ }
1173
1322
  const built = buildCorrectionRequest(target);
1174
1323
  if (!built.ok) {
1175
1324
  target.message = { tone: 'danger', text: built.message };
@@ -0,0 +1,155 @@
1
+ /*
2
+ * Project icon validation and rendering shared by the local and organization
3
+ * Graph screens.
4
+ *
5
+ * Icons are deliberately kept as bounded data URLs. The Graph correction
6
+ * endpoint stores the value in the project metadata, while this module keeps
7
+ * the browser side independent from Node's Buffer implementation so the
8
+ * organization edition can consume the same helper.
9
+ */
10
+
11
+ export const PROJECT_ICON_MAX_BYTES = 256 * 1024;
12
+ export const PROJECT_ICON_MIME_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/webp']);
13
+
14
+ const MIME_LABELS = Object.freeze({
15
+ 'image/png': 'PNG',
16
+ 'image/jpeg': 'JPEG',
17
+ 'image/webp': 'WebP',
18
+ });
19
+
20
+ const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})(?![\s\S])/u;
21
+ const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
22
+ const PNG_SIGNATURE = Object.freeze([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
23
+
24
+ /** A compact representation safe to put in correction history or audit UI. */
25
+ export function projectIconSummary(value) {
26
+ const result = validateProjectIconDataUrl(value);
27
+ if (!result.ok || result.value === null) return null;
28
+ return { present: true, mimeType: result.mimeType, bytes: result.bytes };
29
+ }
30
+
31
+ /**
32
+ * Checks a project icon data URL, including the decoded byte limit and the
33
+ * file signature. `null` means an explicit removal and is valid for a
34
+ * correction; callers can distinguish it from an invalid non-null value.
35
+ */
36
+ export function validateProjectIconDataUrl(value, { maxBytes = PROJECT_ICON_MAX_BYTES } = {}) {
37
+ if (value === null || value === undefined || value === '') return { ok: true, value: null, mimeType: null, bytes: 0 };
38
+ if (typeof value !== 'string') return invalidIcon('type', 'アイコンはPNG、JPEG、WebPの画像を選んでください。');
39
+ const match = DATA_URL_PATTERN.exec(value);
40
+ if (!match) return invalidIcon('format', 'アイコンはPNG、JPEG、WebPの画像を選んでください。');
41
+ const [, mimeType, payload] = match;
42
+ if (!isCanonicalBase64(payload)) return invalidIcon('base64', 'アイコンの画像データを読み取れません。もう一度選んでください。');
43
+ const bytes = decodedLength(payload);
44
+ if (bytes === 0) return invalidIcon('empty', '空の画像は登録できません。');
45
+ if (bytes > maxBytes) return invalidIcon('too_large', `アイコンは${Math.floor(maxBytes / 1024)}KiB以下にしてください。`);
46
+ if (!matchesSignature(mimeType, payload, bytes)) return invalidIcon('signature', '画像形式とデータが一致しません。もう一度選んでください。');
47
+ return { ok: true, value, mimeType, bytes };
48
+ }
49
+
50
+ /** Human-readable type for a validated icon summary. */
51
+ export function projectIconMimeLabel(mimeType) {
52
+ return MIME_LABELS[mimeType] ?? '画像';
53
+ }
54
+
55
+ /** A stable fallback mark for an unregistered project icon. */
56
+ export function projectIconInitial(name) {
57
+ const text = typeof name === 'string' ? name.trim() : '';
58
+ return [...text][0]?.toUpperCase() ?? 'P';
59
+ }
60
+
61
+ /**
62
+ * Render an icon without putting the data URL into text content. Fallback
63
+ * initials are drawn by CSS from `data-initial`, which keeps ledger accessible
64
+ * names and existing screen-reader text compact.
65
+ */
66
+ export function renderProjectIcon(doc, value, name, { size = 'md' } = {}) {
67
+ const valid = validateProjectIconDataUrl(value);
68
+ const registered = valid.ok && valid.value;
69
+ const label = `${typeof name === 'string' && name.trim() ? name.trim() : 'プロジェクト'}のアイコン`;
70
+ const icon = doc.createElement('span');
71
+ icon.className = `bb-project-icon is-${size}${registered ? ' is-registered' : ' is-unregistered'}`;
72
+ icon.setAttribute('aria-label', registered ? label : `${label}(未登録)`);
73
+ icon.setAttribute('title', registered ? label : `${label}(未登録)`);
74
+ icon.setAttribute('data-state', registered ? 'registered' : 'unregistered');
75
+ if (registered) {
76
+ const image = doc.createElement('img');
77
+ image.setAttribute('src', value);
78
+ image.setAttribute('alt', '');
79
+ image.setAttribute('aria-hidden', 'true');
80
+ icon.append(image);
81
+ } else {
82
+ icon.setAttribute('data-initial', projectIconInitial(name));
83
+ }
84
+ return icon;
85
+ }
86
+
87
+ /** Reads a browser File as a data URL; validation is left to the caller. */
88
+ export function readProjectIconFile(file) {
89
+ if (!file) return Promise.reject(new Error('icon_file_required'));
90
+ const Reader = globalThis.FileReader;
91
+ if (typeof Reader === 'function') {
92
+ return new Promise((resolve, reject) => {
93
+ const reader = new Reader();
94
+ reader.addEventListener?.('error', () => reject(new Error('icon_file_read_failed')));
95
+ reader.onerror = () => reject(new Error('icon_file_read_failed'));
96
+ reader.onload = () => {
97
+ if (typeof reader.result !== 'string') reject(new Error('icon_file_read_failed'));
98
+ else resolve(reader.result);
99
+ };
100
+ reader.readAsDataURL(file);
101
+ });
102
+ }
103
+ if (typeof file.arrayBuffer === 'function' && typeof globalThis.btoa === 'function') {
104
+ return file.arrayBuffer().then((buffer) => {
105
+ const bytes = new Uint8Array(buffer);
106
+ let binary = '';
107
+ for (const byte of bytes) binary += String.fromCharCode(byte);
108
+ const mimeType = typeof file.type === 'string' && file.type ? file.type : 'application/octet-stream';
109
+ return `data:${mimeType};base64,${globalThis.btoa(binary)}`;
110
+ });
111
+ }
112
+ return Promise.reject(new Error('icon_file_reader_unavailable'));
113
+ }
114
+
115
+ function invalidIcon(code, message) {
116
+ return { ok: false, code, message };
117
+ }
118
+
119
+ function isCanonicalBase64(value) {
120
+ if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value)) return false;
121
+ const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
122
+ const meaningful = value.length - padding;
123
+ if (meaningful === 0) return false;
124
+ const last = BASE64_ALPHABET.indexOf(value[meaningful - 1]);
125
+ if (last < 0) return false;
126
+ if (padding === 1 && (last & 0x03) !== 0) return false;
127
+ if (padding === 2 && (last & 0x0f) !== 0) return false;
128
+ return true;
129
+ }
130
+
131
+ function decodedLength(value) {
132
+ const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
133
+ return (value.length / 4) * 3 - padding;
134
+ }
135
+
136
+ function base64Byte(value, index) {
137
+ const group = Math.floor(index / 3) * 4;
138
+ const a = BASE64_ALPHABET.indexOf(value[group]);
139
+ const b = BASE64_ALPHABET.indexOf(value[group + 1]);
140
+ const c = value[group + 2] === '=' ? 0 : BASE64_ALPHABET.indexOf(value[group + 2]);
141
+ const d = value[group + 3] === '=' ? 0 : BASE64_ALPHABET.indexOf(value[group + 3]);
142
+ const first = (a << 2) | (b >> 4);
143
+ const second = ((b & 0x0f) << 4) | (c >> 2);
144
+ const third = ((c & 0x03) << 6) | d;
145
+ const offset = index % 3;
146
+ return offset === 0 ? first : offset === 1 ? second : third;
147
+ }
148
+
149
+ function matchesSignature(mimeType, payload, bytes) {
150
+ if (mimeType === 'image/png') return bytes >= PNG_SIGNATURE.length && PNG_SIGNATURE.every((byte, index) => base64Byte(payload, index) === byte);
151
+ if (mimeType === 'image/jpeg') return bytes >= 4 && base64Byte(payload, 0) === 0xff && base64Byte(payload, 1) === 0xd8 && base64Byte(payload, 2) === 0xff
152
+ && base64Byte(payload, bytes - 2) === 0xff && base64Byte(payload, bytes - 1) === 0xd9;
153
+ return bytes >= 12 && base64Byte(payload, 0) === 0x52 && base64Byte(payload, 1) === 0x49 && base64Byte(payload, 2) === 0x46 && base64Byte(payload, 3) === 0x46
154
+ && base64Byte(payload, 8) === 0x57 && base64Byte(payload, 9) === 0x45 && base64Byte(payload, 10) === 0x42 && base64Byte(payload, 11) === 0x50;
155
+ }
@@ -286,9 +286,20 @@
286
286
 
287
287
  .bb-ws-ledger-object {
288
288
  display: grid;
289
+ grid-template-columns: minmax(0, 1fr);
289
290
  gap: 4px;
290
291
  }
291
292
 
293
+ .bb-ws-ledger-object:has(> .bb-project-icon) {
294
+ grid-template-columns: auto minmax(0, 1fr);
295
+ align-items: center;
296
+ }
297
+
298
+ .bb-ws-ledger-object:has(> .bb-project-icon) > strong,
299
+ .bb-ws-ledger-object:has(> .bb-project-icon) > code {
300
+ grid-column: 2;
301
+ }
302
+
292
303
  .bb-ws-ledger-object strong {
293
304
  overflow: hidden;
294
305
  color: var(--bb-color-ink);
@@ -139,7 +139,8 @@ function ledgerRowLabel(cells = []) {
139
139
  /**
140
140
  * A ledger: a header row and selectable rows. `className` sets the column
141
141
  * template in the caller's CSS. A cell is text, an element, `{ text, className }`,
142
- * or `{ primary, secondary }` for a name with its id underneath.
142
+ * or `{ primary, secondary, leading }` for a name with its id underneath and
143
+ * an optional leading element (for example a project icon).
143
144
  */
144
145
  export function workspaceLedger(doc, { columns = [], rows = [], className = '', ariaLabel, empty } = {}) {
145
146
  const ledger = el(doc, 'div', { className: `bb-ws-ledger${className ? ` ${className}` : ''}`, attrs: { role: 'table', 'aria-label': ariaLabel } });
@@ -165,6 +166,7 @@ export function workspaceLedger(doc, { columns = [], rows = [], className = '',
165
166
  for (const cell of row.cells ?? []) {
166
167
  if (cell && typeof cell === 'object' && 'primary' in cell) {
167
168
  const object = el(doc, 'span', { className: 'bb-ws-ledger-object', attrs: { role: 'cell' } });
169
+ if (cell.leading && typeof cell.leading.tagName === 'string') object.append(cell.leading);
168
170
  object.append(el(doc, 'strong', { text: cell.primary }));
169
171
  if (cell.secondary) object.append(el(doc, 'code', { text: cell.secondary }));
170
172
  item.append(object);
@@ -185,9 +187,10 @@ export function workspaceLedger(doc, { columns = [], rows = [], className = '',
185
187
  }
186
188
 
187
189
  /** The selected item's head in the right rail. */
188
- export function workspaceRailHead(doc, { kicker, title, sub, lead } = {}) {
190
+ export function workspaceRailHead(doc, { kicker, title, sub, lead, leading } = {}) {
189
191
  const head = el(doc, 'div', { className: 'bb-ws-rail-head' });
190
192
  if (kicker) head.append(el(doc, 'small', { text: kicker }));
193
+ if (leading && typeof leading.tagName === 'string') head.append(leading);
191
194
  head.append(el(doc, 'h2', { text: title }));
192
195
  if (sub) head.append(el(doc, 'span', { text: sub }));
193
196
  if (lead) head.append(el(doc, 'p', { text: lead }));