@unson/brainbase-mcp 0.15.7 → 0.17.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.
@@ -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). */
@@ -469,45 +481,6 @@ export function hostSourceNotice(sourceNotice, subject) {
469
481
  return { ...sourceNotice, text: sourceNotice.text(subject ?? null) };
470
482
  }
471
483
 
472
- // ---------------------------------------------------------------------------
473
- // The organization's web (Inspector UX-20261004-01)
474
-
475
- const ORGANIZATION_RECORD_ID = /^[A-Za-z0-9_.:-]{1,200}$/u;
476
-
477
- /** The organization web's origin the host was told about, or null. */
478
- export function organizationWebOrigin(organizationGraph) {
479
- if (organizationGraph?.status !== 'connected') return null;
480
- const origin = organizationGraph.web_url;
481
- return typeof origin === 'string' && /^https:\/\/[^/?#@\s]+$/u.test(origin) ? origin : null;
482
- }
483
-
484
- /**
485
- * Where a record opens in the organization web, `{ href, label }`, or null when it has no usable id. The
486
- * organization web opens 情報と関係 by record id and プロジェクトと関係者 by project code, and keeps that record
487
- * across sign-in. A project opens by its own code only: a sub-project's scope code is its parent's and
488
- * would open the parent, so without its own code the record opens by id.
489
- */
490
- export function organizationRecordLink(origin, screenId, record) {
491
- const id = typeof record?.id === 'string' && ORGANIZATION_RECORD_ID.test(record.id) ? record.id : null;
492
- const own = typeof record?.metadata?.code === 'string' ? record.metadata.code.trim() : '';
493
- const code = own && ORGANIZATION_RECORD_ID.test(own) ? own : null;
494
- if (screenId === 'projects' && record?.type === 'project' && code) {
495
- return { href: `${origin}/?screen=projects&project=${encodeURIComponent(code)}`, label: '「プロジェクトと関係者」' };
496
- }
497
- return id ? { href: `${origin}/?screen=graph&entity_id=${encodeURIComponent(id)}`, label: '「情報と関係」' } : null;
498
- }
499
-
500
- export const ORGANIZATION_RECORD_LINK_TEXT = '組織版でこの記録を開く';
501
-
502
- /** A link that opens `target` from organizationRecordLink in a new tab. */
503
- export function organizationRecordAnchor(doc, target, className) {
504
- return makeElement(doc, 'a', {
505
- ...(className ? { className } : {}),
506
- text: ORGANIZATION_RECORD_LINK_TEXT,
507
- attrs: { href: target.href, target: '_blank', rel: 'noopener noreferrer' },
508
- });
509
- }
510
-
511
484
  /**
512
485
  * A host's note that corrections are not available here, or null when it passed none. The host may
513
486
  * pass a function of the selected record (`{ id, type, metadata }`) returning a string or an element,
@@ -757,6 +730,9 @@ export function graphRecordValues(record) {
757
730
  validTo: formatDay(record?.validTo) ?? '',
758
731
  goal: pick(typeof record?.goal === 'string' ? record.goal : metadata.goal),
759
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 : '',
760
736
  role: pick(record?.role),
761
737
  context: pick(record?.context),
762
738
  };
@@ -764,16 +740,32 @@ export function graphRecordValues(record) {
764
740
 
765
741
  function displayValue(field, value) {
766
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
+ }
767
747
  return value ? value : '未記入';
768
748
  }
769
749
 
770
- function valueRows(record, fields) {
750
+ function valueRows(doc, record, fields) {
771
751
  const values = graphRecordValues(record);
772
- 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
+ ]);
773
756
  }
774
757
 
775
758
  function renderValues(doc, record, fields, leadingRows = []) {
776
- 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;
777
769
  }
778
770
 
779
771
  function splitAliases(value) {
@@ -830,6 +822,15 @@ export function buildCorrectionRequest(form) {
830
822
 
831
823
  const changes = {};
832
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
+ }
833
834
  const before = String(form.original[field] ?? '');
834
835
  const after = String(form.draft[field] ?? '');
835
836
  if (after.trim() === before.trim()) continue;
@@ -885,6 +886,29 @@ function fieldControl(doc, form, field, callbacks) {
885
886
  const id = `${form.formId}-${field}`;
886
887
  const wrap = makeElement(doc, 'div', { className: 'bb-graph-field' });
887
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
+ }
888
912
  const control = definition.control === 'textarea'
889
913
  ? makeElement(doc, 'textarea', { attrs: { id, name: field, rows: 3, maxlength: definition.max } })
890
914
  : makeElement(doc, 'input', { attrs: { id, name: field, type: definition.control === 'date' ? 'date' : 'text', maxlength: definition.max } });
@@ -1049,9 +1073,21 @@ export function renderCorrectionPanel(doc, form, callbacks) {
1049
1073
  formElement.append(reasonControl(doc, form, callbacks));
1050
1074
  const actions = makeElement(doc, 'div', { className: 'bb-graph-actions' });
1051
1075
  const saving = form.phase === 'saving';
1052
- 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
+ : '保存する';
1053
1085
  actions.append(
1054
- 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
+ }),
1055
1091
  button(doc, '取り消す', callbacks.onClose, { attrs: { disabled: saving } }),
1056
1092
  );
1057
1093
  formElement.append(actions);
@@ -1073,18 +1109,24 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1073
1109
  let form = null;
1074
1110
  let focusPending = false;
1075
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;
1076
1115
  // Label/input ids stay unique when two screens are on one page.
1077
1116
  const idPrefix = `bb-graph-form-${Math.random().toString(36).slice(2, 8)}`;
1078
1117
  const redraw = () => rerender?.();
1079
1118
 
1080
1119
  function baseForm(kind, extra) {
1081
1120
  sequence += 1;
1121
+ iconReadGeneration += 1;
1082
1122
  focusPending = true;
1083
1123
  return {
1084
1124
  kind,
1085
1125
  formId: `${idPrefix}-${sequence}`,
1086
1126
  phase: 'editing',
1087
1127
  message: null,
1128
+ fieldErrors: {},
1129
+ iconReadPending: false,
1088
1130
  reason: '',
1089
1131
  current: null,
1090
1132
  conflictCode: null,
@@ -1118,7 +1160,60 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1118
1160
 
1119
1161
  const callbacks = {
1120
1162
  onInput(field, value) {
1121
- 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();
1122
1217
  },
1123
1218
  onReason(value) {
1124
1219
  if (form) form.reason = value;
@@ -1154,6 +1249,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1154
1249
  title,
1155
1250
  subject: subject ?? `${typeLabel(record.type)}「${record.name}」`,
1156
1251
  recordType: record.type,
1252
+ recordName: record.name,
1157
1253
  targetId: record.id,
1158
1254
  expectedDigest: record.digest,
1159
1255
  fields: shown,
@@ -1203,12 +1299,26 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1203
1299
  return form;
1204
1300
  },
1205
1301
  close() {
1302
+ iconReadGeneration += 1;
1206
1303
  form = null;
1207
1304
  redraw();
1208
1305
  },
1209
1306
  async submit() {
1210
1307
  const target = form;
1211
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
+ }
1212
1322
  const built = buildCorrectionRequest(target);
1213
1323
  if (!built.ok) {
1214
1324
  target.message = { tone: 'danger', text: built.message };
@@ -21,7 +21,7 @@ import { createObjectiveEditorController } from './objective-editor.js';
21
21
  import { createObjectiveEditorHttpPort } from './objective-editor-http-port.js';
22
22
  import { createValueProofReviewUI } from './value-proof-review.js';
23
23
  import { createWorldModelView } from './world-model-view.js';
24
- import { createGraphClient, organizationRecordAnchor, organizationRecordLink, organizationWebOrigin } from './graph-view-shared.js';
24
+ import { createGraphClient } from './graph-view-shared.js';
25
25
 
26
26
  export const LOCAL_WEB_SHELL_CONTRACT_VERSION = 'brainbase.local-web-shell.v1';
27
27
 
@@ -203,58 +203,11 @@ function mountObjectives(container, context) {
203
203
  return { editor, worldModel };
204
204
  }
205
205
 
206
- const ORGANIZATION_READ_ONLY_NOTE = '組織のGraphを読み取り専用で表示しています。登録の訂正は組織版で行ってください。';
207
- // What the owner needs before leaving for the organization web (Inspector UX-20261004-01): a link to this
208
- // very record there, who may correct it, and what happens after saving.
209
- export { organizationRecordLink };
210
- const organizationReadOnlyNoteLinked = (doc, origin, screenId) => (record) => {
211
- const target = organizationRecordLink(origin, screenId, record);
212
- if (!target) return ORGANIZATION_READ_ONLY_NOTE;
213
- const note = makeElement(doc, 'span');
214
- note.append(
215
- makeElement(doc, 'span', { text: [
216
- '組織のGraphを読み取り専用で表示しています。ここでは直せません。訂正の手順:',
217
- `① 下のリンク(上の「出典」にも同じリンクがあります)から、組織版の${target.label}でこの記録を開きます。ログインしていなければSlackでログインすると、そのままこの記録に戻ります。`,
218
- '② 直せるのは、組織版での役割がgmかceoで、このプロジェクトが属する事業(事業そのものなら、その事業)を許可されている人です。直せるのはプロジェクトの記録(名前・別名・要約・期間・目的・状態)だけで、人物の記録と、関係者の追加・終了は組織版でもまだ直せません。',
219
- '③ 保存すると、理由といっしょに組織のGraphへ書き込まれ、組織版がその場で読み戻して表示します。この画面には、1分ほどたってから再読み込みすると反映されます。',
220
- '',
221
- ].join('\n') }),
222
- organizationRecordAnchor(doc, target),
223
- );
224
- return note;
225
- };
226
-
227
- /**
228
- * The 出典 notice of a screen reading the organization Graph. Once a record is selected its link opens
229
- * that record in the organization web; before that, the screen there.
230
- */
231
- const organizationSourceText = (doc, origin, screenId, sourceText) => (record) => {
232
- const target = record ? organizationRecordLink(origin, screenId, record) : null;
233
- const link = target
234
- ? organizationRecordAnchor(doc, target)
235
- : makeElement(doc, 'a', {
236
- text: '組織版で開いて訂正する',
237
- attrs: { href: `${origin}/?screen=${encodeURIComponent(screenId)}`, target: '_blank', rel: 'noopener noreferrer' },
238
- });
239
- const source = makeElement(doc, 'span');
240
- source.append(makeElement(doc, 'span', { text: `${sourceText} ` }), link);
241
- return source;
242
- };
243
-
244
206
  function mountGraphScreen(screenId, createView) {
245
207
  return (container, context) => {
246
208
  const viewRoot = makeElement(context.document, 'div');
247
209
  makePage(container, context).append(viewRoot);
248
- // Ledger C1: when the host reads the organization Graph, nothing here can be corrected.
249
- const readOnly = context.organizationGraph?.status === 'connected';
250
- const origin = readOnly ? organizationWebOrigin(context.organizationGraph) : null;
251
- const sourceText = `組織のGraph(${context.organizationGraph?.server ?? '接続先不明'})を読み取り専用で表示しています。このMacのGraphではありません。`;
252
210
  return createView({
253
- ...(readOnly ? {
254
- canCorrect: false,
255
- readOnlyNote: origin ? organizationReadOnlyNoteLinked(context.document, origin, screenId) : ORGANIZATION_READ_ONLY_NOTE,
256
- sourceNotice: { label: '出典', text: origin ? organizationSourceText(context.document, origin, screenId, sourceText) : sourceText },
257
- } : {}),
258
211
  root: viewRoot,
259
212
  rail: context.rail,
260
213
  page: context.page,
@@ -334,7 +287,6 @@ function renderSource(doc, bar, status, onRecheck) {
334
287
  bar.append(
335
288
  fact('データ', data.data_dir),
336
289
  fact('Graph', graphLabel, data.graph.status === 'ready' ? '' : 'is-warning'),
337
- fact('組織のGraph', data.organization_graph?.status === 'connected' ? '読んでいます(読み取りのみ)' : '読んでいません'),
338
290
  );
339
291
  }
340
292
 
@@ -446,10 +398,8 @@ export function createLocalWebShell({
446
398
  slot.replaceChildren();
447
399
  const rail = rails.get(screen.id) ?? null;
448
400
  rail?.replaceChildren();
449
- const organizationGraph = status.phase === 'ready' ? status.data.organization_graph ?? null : null;
450
- const readsOrganization = organizationGraph?.status === 'connected' && (screen.id === 'projects' || screen.id === 'graph');
451
- const page = Object.freeze({ crumbs: Object.freeze(['あなたのBrainbase', screen.label]), source: readsOrganization ? '組織のGraph(読み取りのみ)' : screen.source ?? null });
452
- const view = screen.mount(slot, Object.freeze({ ...context, rail, page, initialEntityId: entityId, organizationGraph })) ?? true;
401
+ const page = Object.freeze({ crumbs: Object.freeze(['あなたのBrainbase', screen.label]), source: screen.source ?? null });
402
+ const view = screen.mount(slot, Object.freeze({ ...context, rail, page, initialEntityId: entityId })) ?? true;
453
403
  mounted.set(screen.id, view);
454
404
  if (entityId && screen.id === 'today' && typeof view?.openDecision === 'function') view.openDecision(entityId);
455
405
  if (entityId && screen.id === 'projects' && typeof view?.openProject === 'function') void view.openProject(entityId);
@@ -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 }));