@takagaki/cortex-decisions-viewer 0.12.23 → 0.12.24

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/dist/build.js CHANGED
@@ -36,7 +36,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.PROJECT_ICON_EXTS = void 0;
39
40
  exports.sanitizeBody = sanitizeBody;
41
+ exports.findProjectIcon = findProjectIcon;
40
42
  exports.readFleetSources = readFleetSources;
41
43
  exports.readHarnessSchedules = readHarnessSchedules;
42
44
  exports.readHarnessShelf = readHarnessShelf;
@@ -247,6 +249,48 @@ function resolveKnowledgeRoot(decisionsDirAbs) {
247
249
  }
248
250
  return null;
249
251
  }
252
+ /**
253
+ * 案件アイコン(#348)で受け付ける拡張子。**探索の順でもある**(2枚あれば先頭を使う)。
254
+ *
255
+ * **Lambda 側(infra/lambda/intake/logic.mjs の PROJECT_ICON_EXTS)と同じ並びにすること。**
256
+ * 片方だけ増やす・並べ替えると、intake が書いた名前を build が拾わない(=設定したのに
257
+ * 画面に出ない)か、2枚あるときに intake と build が別の1枚を選ぶ。
258
+ * 突き合わせは viewer/test/project-icon-parity.test.mjs が機械的に見張る。
259
+ *
260
+ * `.jpeg` は入れない——intake は `.jpeg` を `jpg` へ正規化して書くので、
261
+ * リポジトリにその名前は生まれない(拾うと「どちらが正か」が2つになる)。
262
+ */
263
+ exports.PROJECT_ICON_EXTS = ["png", "jpg", "webp"];
264
+ /**
265
+ * `Cortex/icon.<ext>` を探す。無ければ undefined(キーごと出さないため)。
266
+ *
267
+ * **2枚以上あっても落とさない。** 人が GitHub 側で直接置くと起こりうる形なので、
268
+ * 固定順の先頭1枚に倒し、どれを使ったかをビルドログに残す(黙って選ぶと
269
+ * 「差し替えたのに変わらない」の原因が画面からもログからも分からなくなる)。
270
+ */
271
+ async function findProjectIcon(knowledgeRoot) {
272
+ if (!knowledgeRoot)
273
+ return undefined;
274
+ const found = [];
275
+ for (const ext of exports.PROJECT_ICON_EXTS) {
276
+ const file = `icon.${ext}`;
277
+ const srcAbs = path.join(knowledgeRoot, file);
278
+ try {
279
+ const st = await node_fs_1.promises.stat(srcAbs);
280
+ if (st.isFile())
281
+ found.push({ file, repoPath: `${path.basename(knowledgeRoot)}/${file}`, srcAbs });
282
+ }
283
+ catch {
284
+ // 無いだけ(探索なので握りつぶす)
285
+ }
286
+ }
287
+ if (!found.length)
288
+ return undefined;
289
+ if (found.length > 1) {
290
+ console.warn(`案件アイコンが複数あります(${found.map((f) => f.file).join(", ")})。${found[0].file} だけを使います`);
291
+ }
292
+ return found[0];
293
+ }
250
294
  /**
251
295
  * スペースごとの Backlog キーの登録状況で、画面へ運んでよい値。
252
296
  *
@@ -1812,6 +1856,8 @@ async function build(opts) {
1812
1856
  // 走るのは latestGeneratedPaths と同じ **リポジトリルート**(process.cwd())——
1813
1857
  // Decisions ディレクトリで実行すると pathspec に一致せず常に空になる
1814
1858
  const aiHistory = (0, repo_1.collectAiChangeHistory)(process.cwd());
1859
+ // 案件アイコン(#348)。Cortex構成でなければ undefined(旧レイアウトには置き場が無い)
1860
+ const projectIcon = await findProjectIcon(knowledgeRoot);
1815
1861
  const site = {
1816
1862
  title: opts.title,
1817
1863
  generatedAt: opts.generatedAt,
@@ -1862,6 +1908,9 @@ async function build(opts) {
1862
1908
  // 同上(短縮記法にしない)。**undefined のまま渡すのが仕様**——「棚が無い」と
1863
1909
  // 「棚が空」を画面が言い分けるための材料で、ここで [] へ倒すと区別が消える
1864
1910
  harnessShelf: harnessShelf,
1911
+ // 同上(短縮記法にしない)。**undefined ならキーごと出さない**——画面は
1912
+ // 「アイコンがある/無い」をキーの有無で見る(空の器を通さない)
1913
+ projectIcon: projectIcon ? { file: projectIcon.file, repoPath: projectIcon.repoPath } : undefined,
1865
1914
  // null(無い・壊れ・version違い)は undefined へ落とす——SiteData の
1866
1915
  // 「知らないことはキーが無いことで表す」に合わせる(空の器を通さない)
1867
1916
  // 空の器は通さない(0件なら undefined = キーごと出さない)
@@ -1869,6 +1918,15 @@ async function build(opts) {
1869
1918
  };
1870
1919
  // サムネイル(デザイン画面等)をサイトに同梱し、ノードのパスをサイト内相対に書き換える
1871
1920
  await node_fs_1.promises.mkdir(outAbs, { recursive: true });
1921
+ // 案件アイコン(#348)を site/ 直下へ同梱する。
1922
+ // **先に3候補を消す。** 出力ディレクトリは丸ごと作り直されないので、
1923
+ // 「置いてビルド → 外してビルド」をすると古い画像がファイルとして残り、
1924
+ // site-data には出ていないのに URL 直打ちで前のアイコンが見えてしまう
1925
+ for (const ext of exports.PROJECT_ICON_EXTS) {
1926
+ await node_fs_1.promises.rm(path.join(outAbs, `icon.${ext}`), { force: true });
1927
+ }
1928
+ if (projectIcon)
1929
+ await node_fs_1.promises.copyFile(projectIcon.srcAbs, path.join(outAbs, projectIcon.file));
1872
1930
  let thumbIndex = 0;
1873
1931
  for (const node of graph.nodes) {
1874
1932
  if (!node.thumb)
package/dist/render.js CHANGED
@@ -2495,6 +2495,30 @@ a:hover { text-decoration: underline; }
2495
2495
  padding-bottom: 6px; border-bottom: 1px solid var(--border);
2496
2496
  }
2497
2497
  .home-grounding > .home-grounding-title:first-child { margin-top: 0; }
2498
+ /* ---- 案件アイコン(#348)----
2499
+ Home のタイトルの左に丸い枠を置く。ポータルの一覧カード(ProjectIcon.tsx)と
2500
+ 同じ「丸・cover」にする——同じ画像が2つの画面で別の切れ方をすると、
2501
+ 「設定したのと違う絵が出ている」と読まれる */
2502
+ .home-head { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
2503
+ .home-title-wrap { flex: 1 1 320px; min-width: 0; }
2504
+ .project-icon { flex: 0 0 auto; }
2505
+ /* 未設定は破線(置き場が空いていることを見せる)・設定済みは実線 */
2506
+ .project-icon-frame {
2507
+ width: 72px; height: 72px; border-radius: 50%; overflow: hidden;
2508
+ border: 2px dashed var(--border); background: #f6f7f9;
2509
+ display: flex; align-items: center; justify-content: center;
2510
+ padding: 0; cursor: pointer;
2511
+ }
2512
+ .project-icon-frame.project-icon-filled { border-style: solid; }
2513
+ /* 受け口が無い案件では押せないので、押せる見た目にしない */
2514
+ .project-icon-frame:not(button) { cursor: default; }
2515
+ .project-icon-img { width: 100%; height: 100%; object-fit: cover; display: block; }
2516
+ .project-icon-empty { font-size: 11px; color: var(--muted); text-align: center; line-height: 1.3; }
2517
+ .project-icon-actions { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
2518
+ /* 結果の行はタイトルの下へ回す(枠の横に入れると72pxの高さに押し込まれる) */
2519
+ .project-icon-status { flex-basis: 100%; font-size: 13px; }
2520
+ .project-icon-status:empty { display: none; }
2521
+ .project-icon-status-error { color: #b91c1c; }
2498
2522
  /* ---- レコード詳細の「AIによる最近の変更」(リポジトリの git 履歴 由来)----
2499
2523
  カードは詳細ページの提案(.proposals)と同系色にする——どちらも
2500
2524
  「AIがやったこと・言っていることを読む」もので種類が同じ。
@@ -4302,6 +4326,215 @@ ${deriveCapTile.toString()}
4302
4326
  app.appendChild(sec);
4303
4327
  }
4304
4328
 
4329
+ // ---------- 案件アイコン(#348) ----------
4330
+ //
4331
+ // ポータルの案件一覧はカードにアイコンを出す。置き場はこの案件のリポジトリの
4332
+ // Cortex/icon.<ext> で、**設定する口はここ(Home)にしかない**——ポータル側に
4333
+ // 置くと「誰の案件か」の判定を先に作らねばならず、案件を知っている人が
4334
+ // その場で直せる形にならない。
4335
+ //
4336
+ // 保存はビルドを待たずに画面へ反映する(mirror)。恒久化は次のビルドで
4337
+ // site-data ごと焼き直されるので、mirror はこのタブが開いているあいだだけ。
4338
+ // 上限はサーバ側(logic.mjs の PROJECT_ICON_MAX_BYTES)と同じ値。画面の門は
4339
+ // 「送る前に断る」ためのもので、正本はサーバ側
4340
+ var PROJECT_ICON_MAX_BYTES = 1024 * 1024;
4341
+ // 保存直後の反映。null=ビルド時の値(SITE.projectIcon)をそのまま使う。
4342
+ // { file, src } なら設定済み、{ removed: true } なら「今この画面では外れている」
4343
+ var projectIconMirror = null;
4344
+ // いま img に載せている blob:。差し替え・解除のたびに revoke する(掴んだままだと
4345
+ // 選び直すたびにファイルの実体がメモリに積み上がる)
4346
+ var projectIconBlobUrl = null;
4347
+
4348
+ /** いま画面に出すべきアイコン(無ければ null) */
4349
+ function projectIconCurrent() {
4350
+ if (projectIconMirror) {
4351
+ return projectIconMirror.removed ? null : { file: projectIconMirror.file, src: projectIconMirror.src };
4352
+ }
4353
+ var p = SITE.projectIcon;
4354
+ // ビルドが同梱した画像はサイト内の相対パス。**外部URLは載せない**
4355
+ return p ? { file: p.file, src: p.file } : null;
4356
+ }
4357
+
4358
+ /**
4359
+ * 名前から拡張子を引く(jpeg は jpg へ寄せる)。許可外・拡張子なしは null。
4360
+ * **サーバ側(logic.mjs の projectIconExtOf)と同じ判断**を画面でも先にやるのは、
4361
+ * 通らないと分かっているファイルを1MB 送ってから断らないため
4362
+ */
4363
+ function projectIconExtOfName(name) {
4364
+ var m = /\\.([A-Za-z0-9]+)$/.exec(String(name || ""));
4365
+ if (!m) return null;
4366
+ var e = m[1].toLowerCase();
4367
+ if (e === "jpeg") e = "jpg";
4368
+ return e === "png" || e === "jpg" || e === "webp" ? e : null;
4369
+ }
4370
+
4371
+ /** callIntake の戻り(fetch の Promise)を { ok, j } に畳む(submitIntake と同じ形) */
4372
+ function intakeJson(req) {
4373
+ return req.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
4374
+ }
4375
+
4376
+ /**
4377
+ * アイコンの枠(出さないときは null)。
4378
+ *
4379
+ * **押せない枠は出さない。** 受け口(intake)が無く画像も無い案件では、枠ごと出さない
4380
+ *(「+ 画像を選ぶ」を出して押せないのが一番悪い)。画像だけある案件では画像を出し、
4381
+ * 押せる導線は付けない。
4382
+ *
4383
+ * @param status 結果を書く行(枠を描き直しても残る・呼び出し側が持つ)
4384
+ */
4385
+ function renderProjectIcon(status) {
4386
+ var intake = SITE.intake;
4387
+ var cur = projectIconCurrent();
4388
+ if (!intake && !cur) return null;
4389
+
4390
+ var box = el("div", { class: "project-icon", id: "project-icon" });
4391
+ function setStatus(msg, isError) {
4392
+ status.className = "project-icon-status" + (isError ? " project-icon-status-error" : "");
4393
+ status.textContent = msg || "";
4394
+ }
4395
+ // 枠だけを差し替える。status は外にあるので、書いた結果は描き直しでも消えない
4396
+ function redraw() {
4397
+ var next = renderProjectIcon(status);
4398
+ if (next && box.parentNode) box.parentNode.replaceChild(next, box);
4399
+ return next;
4400
+ }
4401
+ var ui = { status: status, setStatus: setStatus, redraw: redraw, input: null, buttons: [] };
4402
+ ui.disable = function (flag) {
4403
+ ui.buttons.forEach(function (b) { b.disabled = !!flag; });
4404
+ };
4405
+
4406
+ // **受け口が無ければ button にしない。** 押せる形(フォーカスが当たる・指差しの
4407
+ // カーソルが出る)で何も起きないのが、画面の嘘のうちで一番たちが悪い
4408
+ var frame = el(intake ? "button" : "div", {
4409
+ class: "project-icon-frame" + (cur ? " project-icon-filled" : ""),
4410
+ "aria-label": "案件アイコン",
4411
+ });
4412
+ if (intake) frame.setAttribute("type", "button");
4413
+ if (cur) {
4414
+ var img = el("img", { class: "project-icon-img", src: cur.src, alt: "案件アイコン" });
4415
+ // **onerror 属性で書かない(CSP でインラインハンドラは動かない)。**
4416
+ // blob: が配信先の img-src で拒まれることがあるので、そのときは文字に倒す
4417
+ img.addEventListener("error", function () {
4418
+ if (!img.parentNode) return;
4419
+ img.parentNode.replaceChild(el("span", { class: "project-icon-empty", text: "保存済み" }), img);
4420
+ });
4421
+ frame.appendChild(img);
4422
+ } else {
4423
+ frame.appendChild(el("span", { class: "project-icon-empty", text: intake ? "+ 画像を選ぶ" : "未設定" }));
4424
+ }
4425
+ box.appendChild(frame);
4426
+
4427
+ if (intake) {
4428
+ var input = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp", hidden: "" });
4429
+ ui.input = input;
4430
+ input.addEventListener("change", function () {
4431
+ var f = input.files && input.files[0];
4432
+ // **読んだらすぐ value を戻す**(成功時だけ戻す形だと、形式で断られた直後に
4433
+ // 同じファイルを選び直しても change が飛ばず、直す経路がここで死ぬ)
4434
+ try { input.value = ""; } catch (e) {}
4435
+ if (f) setProjectIcon(f, ui);
4436
+ });
4437
+ var choose = el("button", { class: "project-icon-choose", type: "button", text: cur ? "画像を変える" : "画像を選ぶ" });
4438
+ choose.addEventListener("click", function () { input.click(); });
4439
+ // 枠を押すのも「選ぶ」と同じ(丸を押したくなるので、押せる場所を分けない)
4440
+ frame.addEventListener("click", function () { input.click(); });
4441
+ ui.buttons.push(choose, frame);
4442
+ var actions = el("div", { class: "project-icon-actions" }, [choose]);
4443
+ if (cur) {
4444
+ var remove = el("button", { class: "project-icon-remove", type: "button", text: "画像を外す" });
4445
+ remove.addEventListener("click", function () { removeProjectIcon(ui); });
4446
+ ui.buttons.push(remove);
4447
+ actions.appendChild(remove);
4448
+ }
4449
+ actions.appendChild(input);
4450
+ box.appendChild(actions);
4451
+ }
4452
+ return box;
4453
+ }
4454
+
4455
+ /** 保存先(GitHub)の1行。submitIntake の modal-paths と同じ組み立て */
4456
+ function projectIconPathBox(repo, p) {
4457
+ var br = SITE.branch || "main";
4458
+ var href = "https://github.com/" + repo + "/blob/" + encodeURIComponent(br) + "/" + p.split("/").map(encodeURIComponent).join("/");
4459
+ return el("div", { class: "modal-paths" }, [
4460
+ el("div", { class: "modal-paths-label", text: "保存先(GitHub):" }),
4461
+ el("a", { class: "modal-path", href: href, target: "_blank", rel: "noopener", text: p }),
4462
+ ]);
4463
+ }
4464
+
4465
+ /**
4466
+ * 画像を1枚選んで保存する。
4467
+ *
4468
+ * **画面側でも門を張る。** サーバ側(planProjectIconSet)が正本だが、ここで断れる
4469
+ * ものを送ると、1MB を上げきってから「大きすぎます」と言うことになる
4470
+ */
4471
+ async function setProjectIcon(file, ui) {
4472
+ function fail(msg) { ui.setStatus("✗ " + msg, true); ui.disable(false); }
4473
+ // 画面側の門。**サーバ側と同じ文言**で、送る前に断る(fail で「✗ 」を揃える)
4474
+ if (!projectIconExtOfName(file.name)) return fail("PNG・JPEG・WebP の画像を選んでください");
4475
+ if (file.size > PROJECT_ICON_MAX_BYTES) return fail("1MB までの画像を選んでください");
4476
+ if (file.size === 0) return fail("画像が空です(0 バイト)。別の画像を選んでください");
4477
+
4478
+ var intake = SITE.intake;
4479
+ var ctype = file.type || "application/octet-stream";
4480
+ ui.disable(true);
4481
+ ui.setStatus("アップロード中…", false);
4482
+
4483
+ var pres;
4484
+ try {
4485
+ pres = await intakeJson(callIntake("presign", {
4486
+ projectKey: intake.projectKey, filename: file.name, contentType: ctype, purpose: "project-icon",
4487
+ }));
4488
+ } catch (net) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4489
+ if (!pres.ok || !pres.j.url) return fail((pres.j && pres.j.message) || "アップロードURLの取得に失敗しました");
4490
+
4491
+ // 署名付きURLへの PUT だけは intake ではないので素の fetch(submitIntake と同じ)
4492
+ var put;
4493
+ try {
4494
+ put = await fetch(pres.j.url, { method: "PUT", headers: { "content-type": ctype }, body: file });
4495
+ } catch (net2) { return fail("アップロードに失敗しました(もう一度お試しください)"); }
4496
+ if (!put.ok) return fail("アップロードに失敗しました(" + put.status + ")");
4497
+
4498
+ var saved;
4499
+ try {
4500
+ saved = await intakeJson(callIntake("project-icon-set", {
4501
+ projectKey: intake.projectKey, key: pres.j.key, filename: file.name,
4502
+ }));
4503
+ } catch (net3) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4504
+ if (!saved.ok) return fail((saved.j && saved.j.message) || "保存できませんでした");
4505
+
4506
+ var repoPath = (saved.j && saved.j.path) || "";
4507
+ if (projectIconBlobUrl) { try { URL.revokeObjectURL(projectIconBlobUrl); } catch (e) {} }
4508
+ projectIconBlobUrl = URL.createObjectURL(file);
4509
+ projectIconMirror = { file: repoPath.split("/").pop() || "icon", src: projectIconBlobUrl };
4510
+ // **同じファイルをもう一度選べるようにする。** value を戻さないと、選び直しても
4511
+ // change が飛ばない(形式で断られた直後に選び直す経路がここで死ぬ)
4512
+ if (ui.input) ui.input.value = "";
4513
+ ui.redraw();
4514
+ ui.setStatus("", false);
4515
+ ui.status.appendChild(el("div", { text: "✓ 保存しました。数分後にポータルの一覧に反映されます。" }));
4516
+ if (repoPath && saved.j.repo) ui.status.appendChild(projectIconPathBox(saved.j.repo, repoPath));
4517
+ }
4518
+
4519
+ /** 画像を外す。**確認は出さない**——押し間違えても、もう一度選び直せば戻る操作 */
4520
+ async function removeProjectIcon(ui) {
4521
+ function fail(msg) { ui.setStatus("✗ " + msg, true); ui.disable(false); }
4522
+ ui.disable(true);
4523
+ ui.setStatus("外しています…", false);
4524
+ var res;
4525
+ try {
4526
+ res = await intakeJson(callIntake("project-icon-remove", { projectKey: SITE.intake.projectKey }));
4527
+ } catch (net) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4528
+ if (!res.ok) return fail((res.j && res.j.message) || "外せませんでした");
4529
+ if (projectIconBlobUrl) {
4530
+ try { URL.revokeObjectURL(projectIconBlobUrl); } catch (e) {}
4531
+ projectIconBlobUrl = null;
4532
+ }
4533
+ projectIconMirror = { removed: true };
4534
+ ui.redraw();
4535
+ ui.setStatus("✓ 外しました。数分後にポータルの一覧に反映されます(案件名の頭文字に戻ります)。", false);
4536
+ }
4537
+
4305
4538
  // ---------- ホーム(着地画面: Markdownをそのまま表示) ----------
4306
4539
  function renderHome() {
4307
4540
  var h = SITE.home;
@@ -4319,10 +4552,18 @@ ${deriveCapTile.toString()}
4319
4552
  text: "最終更新: " + SITE.homeUpdatedAt,
4320
4553
  }));
4321
4554
  }
4322
- var titleWrap = el("div", {}, [ titleEl ]);
4555
+ var titleWrap = el("div", { class: "home-title-wrap" }, [ titleEl ]);
4323
4556
  var ai = h.fm ? adoptionInfo(h.fm.adoption) : null;
4324
4557
  if (ai) titleWrap.appendChild(el("span", { class: "chip adoption-" + String(h.fm.adoption), text: ai.label }));
4325
- head.appendChild(titleWrap);
4558
+ // 案件アイコン(#348)はタイトルの左。**枠と結果の行は同じ flex 行に置く**
4559
+ // ——結果を枠の横に入れると 72px の高さに押し込まれて読めない
4560
+ var homeHead = el("div", { class: "home-head" });
4561
+ var iconStatus = el("div", { class: "project-icon-status", "aria-live": "polite" });
4562
+ var iconBox = renderProjectIcon(iconStatus);
4563
+ if (iconBox) homeHead.appendChild(iconBox);
4564
+ homeHead.appendChild(titleWrap);
4565
+ if (iconBox) homeHead.appendChild(iconStatus);
4566
+ head.appendChild(homeHead);
4326
4567
  var homeEditRow = el("div", { class: "detail-actions-row" }, [
4327
4568
  el("a", { class: "ai-edit-btn", href: codeUrl(HOME_EDIT_PROMPT), target: "_blank", rel: "noopener", text: "AIで編集" }),
4328
4569
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takagaki/cortex-decisions-viewer",
3
- "version": "0.12.23",
3
+ "version": "0.12.24",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,