@takagaki/cortex-decisions-viewer 0.12.23 → 0.12.25

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
@@ -86,6 +86,7 @@ ${styleTag}
86
86
  <body>
87
87
  <header class="site-header">
88
88
  <div class="header-inner">
89
+ <span class="site-icon-slot" id="site-icon-slot"></span>
89
90
  <a class="site-title" href="?" id="home-link">${safeTitle}</a>
90
91
  ${askBtn}
91
92
  ${githubBtn}
@@ -2261,6 +2262,71 @@ a:hover { text-decoration: underline; }
2261
2262
  }
2262
2263
  .site-title { font-size: 18px; font-weight: 700; color: var(--text); }
2263
2264
  .site-title:hover { text-decoration: none; color: var(--accent); }
2265
+ /* ---- 案件アイコン(#348)----
2266
+ **ヘッダーの案件名の左に丸を置く。** ヘッダーは全タブ共通なので、
2267
+ どのタブを開いていても同じ場所に居る。0.12.24 で Home 本文に置いた
2268
+ 「大きな枠+その下のボタン」はオーナー実機で却下された(本文の先頭が重くなった)。
2269
+
2270
+ 見た目はポータルの一覧カード(portal の ProjectIcon.tsx)に寄せる:
2271
+ 丸・cover・未設定は薄い青地に案件名の頭文字1字。同じ画像が2つの画面で
2272
+ 別の切れ方をすると「設定したのと違う絵が出ている」と読まれる */
2273
+ .site-icon-slot { position: relative; flex: 0 0 auto; display: flex; align-items: center; }
2274
+ .site-icon {
2275
+ position: relative;
2276
+ width: 28px; height: 28px; border-radius: 50%; overflow: hidden;
2277
+ display: flex; align-items: center; justify-content: center;
2278
+ padding: 0; border: 0; background: var(--accent-weak); color: var(--accent);
2279
+ font-size: 13px; font-weight: 600; line-height: 1;
2280
+ }
2281
+ .site-icon-initial {
2282
+ display: flex; align-items: center; justify-content: center;
2283
+ width: 100%; height: 100%;
2284
+ }
2285
+ /* **押せる丸の頭文字だけ薄くして、上に「+」を重ねる。**
2286
+ 頭文字だけだと「設定済みの何か」に見え、置ける場所だと分からない
2287
+ ——薄い字の上の「+」で「ここに画像を置ける」を一目にする。
2288
+ 受け口が無い案件(span.site-icon)は置けないので、薄くも「+」も付けない */
2289
+ button.site-icon .site-icon-initial { opacity: .45; }
2290
+ .site-icon-plus {
2291
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
2292
+ display: flex; align-items: center; justify-content: center;
2293
+ color: var(--accent); font-size: 16px; font-weight: 700; line-height: 1;
2294
+ }
2295
+ /* 設定済みの丸は画像だけ。**押せることはホバーで出す**——常時ラベルを重ねると
2296
+ 選んだロゴが読めなくなる(アイコンを設定した意味が薄れる) */
2297
+ .site-icon-hover {
2298
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
2299
+ display: flex; align-items: center; justify-content: center;
2300
+ background: rgba(0, 0, 0, 0.55); color: #fff;
2301
+ font-size: 11px; font-weight: 600; line-height: 1;
2302
+ opacity: 0; transition: opacity 0.12s ease;
2303
+ }
2304
+ button.site-icon:hover .site-icon-hover,
2305
+ button.site-icon[aria-expanded="true"] .site-icon-hover { opacity: 1; }
2306
+ /* **押せることはホバーで示す。** 受け口が無い案件の丸は span なので、この効果は付かない
2307
+ ——押せない丸に指差しカーソルやリングを出すのが、画面の嘘のうちで一番たちが悪い */
2308
+ button.site-icon { cursor: pointer; }
2309
+ button.site-icon:hover, button.site-icon[aria-expanded="true"] { box-shadow: 0 0 0 2px var(--accent); }
2310
+ .site-icon-img { width: 100%; height: 100%; object-fit: cover; display: block; }
2311
+ /* 丸を押したときだけ出るメニュー。スロットを基準に浮かせる(ヘッダーは sticky なので
2312
+ ページを送っても位置がずれない) */
2313
+ .site-icon-menu {
2314
+ position: absolute; top: 36px; left: 0; z-index: 30; min-width: 220px;
2315
+ background: #fff; border: 1px solid var(--border); border-radius: 10px;
2316
+ box-shadow: 0 8px 24px rgba(0,0,0,.12); padding: 8px;
2317
+ display: flex; flex-direction: column; gap: 6px;
2318
+ }
2319
+ .site-icon-menu[hidden] { display: none; }
2320
+ .site-icon-menu button {
2321
+ text-align: left; padding: 8px 10px; border-radius: 8px;
2322
+ border: 1px solid var(--border); background: #fff; font-size: 13px; cursor: pointer;
2323
+ }
2324
+ .site-icon-menu button:hover { background: #f3f6fb; }
2325
+ .site-icon-menu button:disabled { opacity: .6; cursor: default; }
2326
+ /* 結果の行はメニューの中に置く(丸の横に出すとヘッダーの高さに押し込まれて読めない) */
2327
+ .site-icon-status { font-size: 12.5px; padding: 2px 4px; }
2328
+ .site-icon-status:empty { display: none; }
2329
+ .site-icon-status-error { color: #b91c1c; }
2264
2330
  .record-count { color: var(--muted); font-size: 13px; }
2265
2331
  .ask-ai-btn {
2266
2332
  margin-left: auto; flex: 0 0 auto;
@@ -4302,6 +4368,294 @@ ${deriveCapTile.toString()}
4302
4368
  app.appendChild(sec);
4303
4369
  }
4304
4370
 
4371
+ // ---------- 案件アイコン(#348) ----------
4372
+ //
4373
+ // ポータルの案件一覧はカードにアイコンを出す。置き場はこの案件のリポジトリの
4374
+ // Cortex/icon.<ext> で、**設定する口はこのビューアにしかない**——ポータル側に
4375
+ // 置くと「誰の案件か」の判定を先に作らねばならず、案件を知っている人が
4376
+ // その場で直せる形にならない。
4377
+ //
4378
+ // **出す場所はヘッダーの案件名の左**(0.12.24 の Home 本文から移した・#348)。
4379
+ // ヘッダーは全タブ共通なので、どのタブを開いていても同じ場所に居る。
4380
+ // 押せる口はメニューの中に畳む——常時見えるボタンは、たまにしか使わない操作には重い。
4381
+ //
4382
+ // 保存はビルドを待たずに画面へ反映する(mirror)。恒久化は次のビルドで
4383
+ // site-data ごと焼き直されるので、mirror はこのタブが開いているあいだだけ。
4384
+ // 上限はサーバ側(logic.mjs の PROJECT_ICON_MAX_BYTES)と同じ値。画面の門は
4385
+ // 「送る前に断る」ためのもので、正本はサーバ側
4386
+ var PROJECT_ICON_MAX_BYTES = 1024 * 1024;
4387
+ // 保存直後の反映。null=ビルド時の値(SITE.projectIcon)をそのまま使う。
4388
+ // { file, src } なら設定済み、{ removed: true } なら「今この画面では外れている」
4389
+ var projectIconMirror = null;
4390
+ // いま img に載せている blob:。差し替え・解除のたびに revoke する(掴んだままだと
4391
+ // 選び直すたびにファイルの実体がメモリに積み上がる)
4392
+ var projectIconBlobUrl = null;
4393
+
4394
+ /** いま画面に出すべきアイコン(無ければ null) */
4395
+ function projectIconCurrent() {
4396
+ if (projectIconMirror) {
4397
+ return projectIconMirror.removed ? null : { file: projectIconMirror.file, src: projectIconMirror.src };
4398
+ }
4399
+ var p = SITE.projectIcon;
4400
+ // ビルドが同梱した画像はサイト内の相対パス。**外部URLは載せない**
4401
+ return p ? { file: p.file, src: p.file } : null;
4402
+ }
4403
+
4404
+ /**
4405
+ * 名前から拡張子を引く(jpeg は jpg へ寄せる)。許可外・拡張子なしは null。
4406
+ * **サーバ側(logic.mjs の projectIconExtOf)と同じ判断**を画面でも先にやるのは、
4407
+ * 通らないと分かっているファイルを1MB 送ってから断らないため
4408
+ */
4409
+ function projectIconExtOfName(name) {
4410
+ var m = /\\.([A-Za-z0-9]+)$/.exec(String(name || ""));
4411
+ if (!m) return null;
4412
+ var e = m[1].toLowerCase();
4413
+ if (e === "jpeg") e = "jpg";
4414
+ return e === "png" || e === "jpg" || e === "webp" ? e : null;
4415
+ }
4416
+
4417
+ /** callIntake の戻り(fetch の Promise)を { ok, j } に畳む(submitIntake と同じ形) */
4418
+ function intakeJson(req) {
4419
+ return req.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); });
4420
+ }
4421
+
4422
+ // ヘッダーの丸のメニューが開いているか。**DOM ではなくこの変数が正本。**
4423
+ // 保存・解除のあとに丸を描き直すので、開閉を DOM だけに持たせると描き直しで畳まれ、
4424
+ // 「✓ 保存しました」と保存先のリンクが一度も見えないまま消える
4425
+ var siteIconMenuOpen = false;
4426
+ // document に付ける開閉のリスナーは1組だけ。描き直しのたびに足すと Escape 1回で
4427
+ // 同じ処理が何度も走る(見た目は同じなので気づけない)
4428
+ var siteIconGlobalBound = false;
4429
+
4430
+ /**
4431
+ * 案件名の頭文字1字。**ポータルの ProjectIcon.tsx(initialOf)と同じ取り方**にする
4432
+ * ——同じ案件がポータルとビューアで違う字になると「別の案件を見ている」と読まれる。
4433
+ *
4434
+ * ⚠ title[0] にしない。サロゲートペア(絵文字・一部の漢字)が半分に割れて化ける
4435
+ */
4436
+ function siteInitial() {
4437
+ return Array.from(String(SITE.title || "").trim())[0] || "?";
4438
+ }
4439
+
4440
+ /** 丸の中の頭文字(薄くするかどうかは CSS が丸の種類で決める) */
4441
+ function siteIconInitialEl() {
4442
+ return el("span", { class: "site-icon-initial", text: siteInitial() });
4443
+ }
4444
+
4445
+ /** メニューの開閉。描き直さずに hidden と aria-expanded だけ動かす */
4446
+ function setSiteIconMenu(open) {
4447
+ siteIconMenuOpen = !!open;
4448
+ var slot = document.getElementById("site-icon-slot");
4449
+ if (!slot) return;
4450
+ var menu = slot.querySelector(".site-icon-menu");
4451
+ if (menu) {
4452
+ if (siteIconMenuOpen) menu.removeAttribute("hidden");
4453
+ else menu.setAttribute("hidden", "");
4454
+ }
4455
+ var circle = slot.querySelector("button.site-icon");
4456
+ if (circle) circle.setAttribute("aria-expanded", siteIconMenuOpen ? "true" : "false");
4457
+ }
4458
+
4459
+ /**
4460
+ * ヘッダーの案件名の左の丸を描く(#site-icon-slot の中身を作り直す)。
4461
+ *
4462
+ * **丸は常に出す**(未設定なら頭文字)——ポータルの一覧と同じ顔をここでも見せる。
4463
+ * **押せる形にするのは受け口(intake)がある案件だけ。** 無ければ span にして
4464
+ * メニューもファイル選択も付けない——押して何も起きない口は「壊れている」と読まれる。
4465
+ *
4466
+ * @param status 前の描画から引き継ぐ結果の行(省略時は新しく作る)
4467
+ */
4468
+ function renderSiteIcon(status) {
4469
+ var slot = document.getElementById("site-icon-slot");
4470
+ if (!slot) return;
4471
+ var intake = SITE.intake;
4472
+ var cur = projectIconCurrent();
4473
+ slot.textContent = "";
4474
+
4475
+ var circle = el(intake ? "button" : "span", { class: "site-icon" });
4476
+ if (intake) {
4477
+ circle.setAttribute("type", "button");
4478
+ circle.setAttribute("aria-haspopup", "menu");
4479
+ circle.setAttribute("aria-expanded", siteIconMenuOpen ? "true" : "false");
4480
+ // 未設定は「+」が見えているので、説明は「変更」ではなく置ける話にしない——
4481
+ // 読み上げと tooltip は、いまの状態でできることをそのまま言う
4482
+ var label = cur ? "案件アイコンを変更/外す" : "案件アイコンを設定";
4483
+ circle.setAttribute("title", label);
4484
+ circle.setAttribute("aria-label", label);
4485
+ } else {
4486
+ // すぐ隣に案件名が文字で出ているので、読み上げで名前を2回言わせない
4487
+ circle.setAttribute("aria-hidden", "true");
4488
+ }
4489
+ if (cur) {
4490
+ // **設定済みは画像だけ。** 頭文字も「+」も重ねない(選んだロゴが読めなくなる)
4491
+ var img = el("img", { class: "site-icon-img", src: cur.src, alt: "" });
4492
+ // **onerror 属性で書かない(CSP でインラインハンドラは動かない)。**
4493
+ // blob: が配信先の img-src で拒まれることがあるので、そのときは頭文字に倒す
4494
+ img.addEventListener("error", function () {
4495
+ if (img.parentNode) img.parentNode.replaceChild(siteIconInitialEl(), img);
4496
+ });
4497
+ circle.appendChild(img);
4498
+ // 押せる案件だけ、ホバーで「変更」と出す(見た目は CSS の opacity で切り替える)
4499
+ if (intake) circle.appendChild(el("span", { class: "site-icon-hover", "aria-hidden": "true", text: "変更" }));
4500
+ } else {
4501
+ circle.appendChild(siteIconInitialEl());
4502
+ // **未設定の押せる丸には「+」を重ねる。** 薄い頭文字だけだと
4503
+ // 「設定済みの何か」に見えて、置ける場所だと分からない
4504
+ if (intake) circle.appendChild(el("span", { class: "site-icon-plus", "aria-hidden": "true", text: "+" }));
4505
+ }
4506
+ slot.appendChild(circle);
4507
+ if (!intake) return;
4508
+
4509
+ circle.addEventListener("click", function () { setSiteIconMenu(!siteIconMenuOpen); });
4510
+
4511
+ // **status の要素は描き直しをまたいで持ち回る。** 描き直したあとに
4512
+ // 「✓ 保存しました」と保存先のリンクを足すので、入れ替わると書いた先が画面から外れる
4513
+ var st = status || el("div", { class: "site-icon-status", "aria-live": "polite" });
4514
+ var menu = el("div", { class: "site-icon-menu", role: "menu" });
4515
+ if (!siteIconMenuOpen) menu.setAttribute("hidden", "");
4516
+
4517
+ function setStatus(msg, isError) {
4518
+ st.className = "site-icon-status" + (isError ? " site-icon-status-error" : "");
4519
+ st.textContent = msg || "";
4520
+ }
4521
+ var ui = {
4522
+ status: st,
4523
+ setStatus: setStatus,
4524
+ // 描き直すと押していたボタンが DOM から消えてフォーカスが body に落ちるので、
4525
+ // 丸へ戻す(キーボードの人が位置を失わない。Escape は document 側なので効く)
4526
+ redraw: function () {
4527
+ renderSiteIcon(st);
4528
+ var s = document.getElementById("site-icon-slot");
4529
+ var c = s && s.querySelector("button.site-icon");
4530
+ if (c && typeof c.focus === "function") c.focus();
4531
+ },
4532
+ input: null,
4533
+ buttons: [],
4534
+ };
4535
+ ui.disable = function (flag) { ui.buttons.forEach(function (b) { b.disabled = !!flag; }); };
4536
+
4537
+ var input = el("input", { type: "file", accept: "image/png,image/jpeg,image/webp", hidden: "" });
4538
+ ui.input = input;
4539
+ input.addEventListener("change", function () {
4540
+ var f = input.files && input.files[0];
4541
+ // **読んだらすぐ value を戻す**(成功時だけ戻す形だと、形式で断られた直後に
4542
+ // 同じファイルを選び直しても change が飛ばず、直す経路がここで死ぬ)
4543
+ try { input.value = ""; } catch (e) {}
4544
+ if (f) setProjectIcon(f, ui);
4545
+ });
4546
+ var choose = el("button", { class: "site-icon-choose", type: "button", role: "menuitem", text: cur ? "画像を変える" : "画像を選ぶ" });
4547
+ choose.addEventListener("click", function () { input.click(); });
4548
+ ui.buttons.push(choose);
4549
+ menu.appendChild(choose);
4550
+ if (cur) {
4551
+ var remove = el("button", { class: "site-icon-remove", type: "button", role: "menuitem", text: "画像を外す" });
4552
+ remove.addEventListener("click", function () { removeProjectIcon(ui); });
4553
+ ui.buttons.push(remove);
4554
+ menu.appendChild(remove);
4555
+ }
4556
+ menu.appendChild(input);
4557
+ menu.appendChild(st);
4558
+ slot.appendChild(menu);
4559
+
4560
+ if (!siteIconGlobalBound) {
4561
+ siteIconGlobalBound = true;
4562
+ // 外を押したら閉じる。**スロットの中では閉じない**——「画像を選ぶ」を押した
4563
+ // 瞬間に畳まれると、選び終えたときに結果を書く場所が無い
4564
+ document.addEventListener("click", function (ev) {
4565
+ if (!siteIconMenuOpen) return;
4566
+ var s = document.getElementById("site-icon-slot");
4567
+ if (s && s.contains(ev.target)) return;
4568
+ setSiteIconMenu(false);
4569
+ });
4570
+ document.addEventListener("keydown", function (ev) {
4571
+ if (siteIconMenuOpen && (ev.key === "Escape" || ev.key === "Esc")) setSiteIconMenu(false);
4572
+ });
4573
+ }
4574
+ }
4575
+
4576
+ /** 保存先(GitHub)の1行。submitIntake の modal-paths と同じ組み立て */
4577
+ function projectIconPathBox(repo, p) {
4578
+ var br = SITE.branch || "main";
4579
+ var href = "https://github.com/" + repo + "/blob/" + encodeURIComponent(br) + "/" + p.split("/").map(encodeURIComponent).join("/");
4580
+ return el("div", { class: "modal-paths" }, [
4581
+ el("div", { class: "modal-paths-label", text: "保存先(GitHub):" }),
4582
+ el("a", { class: "modal-path", href: href, target: "_blank", rel: "noopener", text: p }),
4583
+ ]);
4584
+ }
4585
+
4586
+ /**
4587
+ * 画像を1枚選んで保存する。
4588
+ *
4589
+ * **画面側でも門を張る。** サーバ側(planProjectIconSet)が正本だが、ここで断れる
4590
+ * ものを送ると、1MB を上げきってから「大きすぎます」と言うことになる
4591
+ */
4592
+ async function setProjectIcon(file, ui) {
4593
+ function fail(msg) { ui.setStatus("✗ " + msg, true); ui.disable(false); }
4594
+ // 画面側の門。**サーバ側と同じ文言**で、送る前に断る(fail で「✗ 」を揃える)
4595
+ if (!projectIconExtOfName(file.name)) return fail("PNG・JPEG・WebP の画像を選んでください");
4596
+ if (file.size > PROJECT_ICON_MAX_BYTES) return fail("1MB までの画像を選んでください");
4597
+ if (file.size === 0) return fail("画像が空です(0 バイト)。別の画像を選んでください");
4598
+
4599
+ var intake = SITE.intake;
4600
+ var ctype = file.type || "application/octet-stream";
4601
+ ui.disable(true);
4602
+ ui.setStatus("アップロード中…", false);
4603
+
4604
+ var pres;
4605
+ try {
4606
+ pres = await intakeJson(callIntake("presign", {
4607
+ projectKey: intake.projectKey, filename: file.name, contentType: ctype, purpose: "project-icon",
4608
+ }));
4609
+ } catch (net) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4610
+ if (!pres.ok || !pres.j.url) return fail((pres.j && pres.j.message) || "アップロードURLの取得に失敗しました");
4611
+
4612
+ // 署名付きURLへの PUT だけは intake ではないので素の fetch(submitIntake と同じ)
4613
+ var put;
4614
+ try {
4615
+ put = await fetch(pres.j.url, { method: "PUT", headers: { "content-type": ctype }, body: file });
4616
+ } catch (net2) { return fail("アップロードに失敗しました(もう一度お試しください)"); }
4617
+ if (!put.ok) return fail("アップロードに失敗しました(" + put.status + ")");
4618
+
4619
+ var saved;
4620
+ try {
4621
+ saved = await intakeJson(callIntake("project-icon-set", {
4622
+ projectKey: intake.projectKey, key: pres.j.key, filename: file.name,
4623
+ }));
4624
+ } catch (net3) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4625
+ if (!saved.ok) return fail((saved.j && saved.j.message) || "保存できませんでした");
4626
+
4627
+ var repoPath = (saved.j && saved.j.path) || "";
4628
+ if (projectIconBlobUrl) { try { URL.revokeObjectURL(projectIconBlobUrl); } catch (e) {} }
4629
+ projectIconBlobUrl = URL.createObjectURL(file);
4630
+ projectIconMirror = { file: repoPath.split("/").pop() || "icon", src: projectIconBlobUrl };
4631
+ // **同じファイルをもう一度選べるようにする。** value を戻さないと、選び直しても
4632
+ // change が飛ばない(形式で断られた直後に選び直す経路がここで死ぬ)
4633
+ if (ui.input) ui.input.value = "";
4634
+ ui.redraw();
4635
+ ui.setStatus("", false);
4636
+ ui.status.appendChild(el("div", { text: "✓ 保存しました。数分後にポータルの一覧に反映されます。" }));
4637
+ if (repoPath && saved.j.repo) ui.status.appendChild(projectIconPathBox(saved.j.repo, repoPath));
4638
+ }
4639
+
4640
+ /** 画像を外す。**確認は出さない**——押し間違えても、もう一度選び直せば戻る操作 */
4641
+ async function removeProjectIcon(ui) {
4642
+ function fail(msg) { ui.setStatus("✗ " + msg, true); ui.disable(false); }
4643
+ ui.disable(true);
4644
+ ui.setStatus("外しています…", false);
4645
+ var res;
4646
+ try {
4647
+ res = await intakeJson(callIntake("project-icon-remove", { projectKey: SITE.intake.projectKey }));
4648
+ } catch (net) { return fail("サーバーに接続できませんでした。通信環境を確認して、もう一度お試しください。"); }
4649
+ if (!res.ok) return fail((res.j && res.j.message) || "外せませんでした");
4650
+ if (projectIconBlobUrl) {
4651
+ try { URL.revokeObjectURL(projectIconBlobUrl); } catch (e) {}
4652
+ projectIconBlobUrl = null;
4653
+ }
4654
+ projectIconMirror = { removed: true };
4655
+ ui.redraw();
4656
+ ui.setStatus("✓ 外しました。数分後にポータルの一覧に反映されます(案件名の頭文字に戻ります)。", false);
4657
+ }
4658
+
4305
4659
  // ---------- ホーム(着地画面: Markdownをそのまま表示) ----------
4306
4660
  function renderHome() {
4307
4661
  var h = SITE.home;
@@ -4322,6 +4676,8 @@ ${deriveCapTile.toString()}
4322
4676
  var titleWrap = el("div", {}, [ titleEl ]);
4323
4677
  var ai = h.fm ? adoptionInfo(h.fm.adoption) : null;
4324
4678
  if (ai) titleWrap.appendChild(el("span", { class: "chip adoption-" + String(h.fm.adoption), text: ai.label }));
4679
+ // 案件アイコン(#348)はここには置かない。**ヘッダーの案件名の左**にある
4680
+ // (renderSiteIcon)——全タブから同じ場所で触れる形にした
4325
4681
  head.appendChild(titleWrap);
4326
4682
  var homeEditRow = el("div", { class: "detail-actions-row" }, [
4327
4683
  el("a", { class: "ai-edit-btn", href: codeUrl(HOME_EDIT_PROMPT), target: "_blank", rel: "noopener", text: "AIで編集" }),
@@ -14126,6 +14482,10 @@ ${deriveCapTile.toString()}
14126
14482
  }
14127
14483
 
14128
14484
  window.addEventListener("popstate", route);
14485
+ // ヘッダーの案件アイコン(#348)は #app の外なので、**起動時に1回だけ描く**。
14486
+ // route() はタブを替えるたびに走るが、ヘッダーは描き替えない——描き直すと
14487
+ // 保存直後の mirror とメニューの開閉が、タブを移るたびに畳まれる
14488
+ renderSiteIcon();
14129
14489
  route();
14130
14490
  /**
14131
14491
  * 接続状況の先読み(#264)。**開いたタブに関係なく、起動時に1回だけ取りに行く。**
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.25",
4
4
  "description": "Cortexの意思決定記録(Decisions/*.md)を静的サイトにビルドして閲覧する。各レコードに「Edit on GitHub」リンクを付与する。",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,