@cyber-dash-tech/revela 0.17.0 → 0.17.2

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.
@@ -1,4 +1,4 @@
1
- import type { NarrativeMap, NarrativeMapClaim, NarrativeMapClaimRelation } from "./map"
1
+ import type { NarrativeMap, NarrativeMapClaim, NarrativeMapClaimRelation, NarrativeMapResearchGap } from "./map"
2
2
  import { emptyDisplayModel, isChineseLanguage, isJapaneseLanguage, relationKey, type ValidatedNarrativeDisplayModel } from "./display"
3
3
 
4
4
  interface FlowNode {
@@ -6,7 +6,14 @@ interface FlowNode {
6
6
  claim: NarrativeMapClaim
7
7
  title: string
8
8
  displayCard?: ReturnType<ValidatedNarrativeDisplayModel["claimCards"]["get"]>
9
- detailHtml: string
9
+ claimHtml: string
10
+ claimPanelTitle: string
11
+ claimPanelSubtitle: string
12
+ initialDetailId?: string
13
+ initialDetailHtml: string
14
+ initialDetailTitle: string
15
+ initialDetailSubtitle: string
16
+ detailTemplates: string
10
17
  }
11
18
 
12
19
  export function renderNarrativeMapHtml(map: NarrativeMap, display?: ValidatedNarrativeDisplayModel): string {
@@ -19,8 +26,7 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
19
26
  const initial = nodes[0]
20
27
  const inferredCount = map.claimRelations.filter((relation) => relation.inferred).length
21
28
  const pageTitle = display.pageTitle ?? valueOrFallback(map.snapshot.thesis, map.snapshot.decisionAction || "Narrative claim flow")
22
- const summaryLine = display.summaryLine ?? "Claims are the main path. Evidence, risks, gaps, objections, and artifact coverage stay in the selected-claim panel."
23
- const nonCurrentArtifacts = map.artifactCoverage.filter((artifact) => artifact.coverageStatus !== "current").length
29
+ const summaryLine = display.summaryLine ?? "Select a claim to read its evidence and gaps. Evidence cards show what the source says, why it supports the claim, and where it came from."
24
30
  return `<!doctype html>
25
31
  <html lang="${escapeAttr(display.language)}">
26
32
  <head>
@@ -28,59 +34,83 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
28
34
  <meta name="viewport" content="width=device-width, initial-scale=1" />
29
35
  <title>${escapeHtml(title)}</title>
30
36
  <style>
31
- :root { color-scheme:light; --bg:#f5f1e9; --paper:#fffdf8; --ink:#1c1917; --muted:#766d63; --line:#ded4c7; --accent:#d8612b; --good:#177044; --warn:#a56015; --bad:#a33434; --soft:#f7f0e7; --shadow:0 20px 54px rgba(54,43,31,.13); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
37
+ :root { color-scheme:light; --bg:#f5f1e9; --paper:#fffdf8; --ink:#1c1917; --muted:#766d63; --line:#ded4c7; --accent:#d8612b; --good:#177044; --warn:#a56015; --bad:#a33434; --gap:#6d4aa2; --soft:#f7f0e7; --shadow:0 20px 54px rgba(54,43,31,.13); --reading-font:"EB Garamond","Cormorant Garamond",Garamond,Georgia,serif; --ui-font:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; font-family:var(--reading-font); }
32
38
  * { box-sizing:border-box; }
33
39
  body { margin:0; min-height:100vh; background:radial-gradient(circle at 12% 0,#fff7ed 0,transparent 26rem),radial-gradient(circle at 85% 10%,#edf4f0 0,transparent 24rem),var(--bg); color:var(--ink); }
34
40
  .shell { max-width:1440px; margin:0 auto; padding:22px; }
35
41
  .topbar { background:rgba(255,253,248,.9); border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow); padding:20px 22px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:18px; align-items:start; }
36
- .eyebrow { margin:0; color:var(--accent); font-size:12px; font-weight:850; letter-spacing:.15em; text-transform:uppercase; }
42
+ .eyebrow { margin:0; color:var(--accent); font-family:var(--ui-font); font-size:12px; font-weight:850; letter-spacing:.15em; text-transform:uppercase; }
37
43
  h1 { margin:7px 0 0; max-width:860px; font-size:clamp(24px,3.2vw,42px); line-height:1.02; letter-spacing:-.05em; }
38
- .summary { margin:10px 0 0; color:var(--muted); font-size:14px; line-height:1.45; max-width:920px; }
44
+ .summary { margin:10px 0 0; color:var(--muted); font-size:17px; line-height:1.45; max-width:920px; }
39
45
  .pills { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
40
- .pill { display:inline-flex; border-radius:999px; padding:7px 10px; font-size:12px; font-weight:780; border:1px solid var(--line); background:#fff; color:var(--muted); white-space:nowrap; }
46
+ .pill { display:inline-flex; border-radius:999px; padding:7px 10px; font-family:var(--ui-font); font-size:12px; font-weight:780; border:1px solid var(--line); background:#fff; color:var(--muted); white-space:nowrap; }
41
47
  .pill.current,.pill.supported { color:var(--good); background:#e8f4ed; border-color:#b9dcc8; }
42
48
  .pill.stale,.pill.missing { color:var(--bad); background:#fbe7e7; border-color:#efb9b9; }
43
49
  .pill.partial,.pill.weak,.pill.open { color:var(--warn); background:#fff1dc; border-color:#edd0a5; }
44
- .layout { display:grid; grid-template-columns:minmax(0,1fr) minmax(360px,430px); gap:18px; margin-top:18px; align-items:start; }
45
- .flow,.detail-panel { background:rgba(255,253,248,.92); border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow); }
50
+ .layout { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; margin-top:18px; align-items:start; }
51
+ .flow,.claim-panel,.detail-panel { background:rgba(255,253,248,.92); border:1px solid var(--line); border-radius:24px; box-shadow:var(--shadow); }
46
52
  .flow { padding:20px; }
47
53
  .flow-head { display:flex; justify-content:space-between; gap:14px; align-items:flex-start; margin-bottom:18px; }
48
54
  .flow-head h2 { margin:0; font-size:18px; letter-spacing:-.025em; }
49
- .flow-note { margin:4px 0 0; color:var(--muted); font-size:13px; line-height:1.45; }
55
+ .flow-note { margin:4px 0 0; color:var(--muted); font-size:15px; line-height:1.45; }
50
56
  .claim-list { display:flex; flex-direction:column; gap:0; }
51
57
  .claim-step { display:grid; grid-template-columns:42px minmax(0,1fr); gap:14px; }
52
58
  .step-rail { display:flex; flex-direction:column; align-items:center; }
53
59
  .step-dot { width:32px; height:32px; border-radius:999px; display:grid; place-items:center; background:#fff; border:1px solid var(--line); color:var(--muted); font-size:12px; font-weight:850; }
54
60
  .step-line { flex:1; width:2px; min-height:30px; background:linear-gradient(var(--line),rgba(222,212,199,.25)); margin:8px 0; }
55
61
  .claim-step:last-child .step-line { display:none; }
56
- .claim-card { width:100%; text-align:left; cursor:pointer; border:1px solid var(--line); border-left:6px solid var(--good); background:#fff; color:var(--ink); border-radius:18px; padding:15px 16px; margin-bottom:16px; box-shadow:0 10px 26px rgba(67,49,31,.08); transition:border-color .15s ease,box-shadow .15s ease,transform .15s ease; }
57
- .claim-card:hover,.claim-card.active { border-color:var(--accent); box-shadow:0 14px 32px rgba(143,62,24,.15); transform:translateY(-1px); }
62
+ .claim-card { width:100%; min-width:0; text-align:left; cursor:pointer; border:0; border-left:6px solid var(--good); background:transparent; color:var(--ink); border-radius:0; padding:15px 16px; margin-bottom:16px; box-shadow:none; font-family:var(--reading-font); transition:none; }
63
+ .claim-card:hover .claim-title,.claim-card.active .claim-title { color:var(--accent); }
64
+ .claim-card.supported { border-left-color:var(--good); }
58
65
  .claim-card.partial,.claim-card.weak { border-left-color:var(--warn); }
59
66
  .claim-card.missing { border-left-color:var(--bad); }
60
- .claim-title { display:block; font-size:18px; font-weight:850; line-height:1.2; letter-spacing:-.018em; }
61
- .claim-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; }
62
- .tag { display:inline-flex; border-radius:999px; padding:4px 8px; background:var(--soft); color:var(--muted); font-size:11px; font-weight:800; }
67
+ .claim-card.not_required { border-left-color:var(--line); }
68
+ .claim-title { display:block; min-width:0; font-size:20px; font-weight:850; line-height:1.18; letter-spacing:-.018em; overflow-wrap:anywhere; }
69
+ .claim-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:10px; min-width:0; }
70
+ .tag { display:inline-flex; min-width:0; max-width:100%; border-radius:999px; padding:4px 8px; background:var(--soft); color:var(--muted); font-family:var(--ui-font); font-size:11px; font-weight:800; white-space:normal; overflow-wrap:anywhere; word-break:break-word; }
63
71
  .claim-sections { margin-top:13px; display:grid; gap:9px; }
64
72
  .claim-section { border-top:1px solid #eee4d8; padding-top:9px; }
65
- .section-label { display:block; margin-bottom:3px; color:var(--accent); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; }
66
- .section-text { display:block; color:#51483f; font-size:13px; line-height:1.46; white-space:pre-line; }
73
+ .section-label { display:block; margin-bottom:3px; color:var(--accent); font-family:var(--ui-font); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; }
74
+ .section-text { display:block; min-width:0; color:#51483f; font-family:var(--reading-font); font-size:15px; line-height:1.46; white-space:pre-line; overflow-wrap:anywhere; }
67
75
  .relation-strip { margin-top:12px; display:grid; gap:7px; }
68
- .relation { display:grid; grid-template-columns:auto minmax(0,1fr); gap:8px; align-items:flex-start; color:var(--muted); font-size:13px; line-height:1.35; }
69
- .relation-badge { flex:0 0 auto; border-radius:999px; padding:3px 7px; background:#fff4e8; color:#9c4d1d; border:1px solid #efcfb8; font-size:10px; font-weight:850; text-transform:uppercase; letter-spacing:.04em; }
70
- .relation-target { display:block; color:#51483f; font-weight:720; }
71
- .relation-note { display:block; margin-top:3px; color:var(--muted); }
76
+ .relation { display:grid; grid-template-columns:1fr; gap:6px; align-items:flex-start; color:var(--muted); font-size:14px; line-height:1.35; min-width:0; }
77
+ .relation-badge { width:fit-content; max-width:100%; border-radius:999px; padding:3px 7px; background:#fff4e8; color:#9c4d1d; border:1px solid #efcfb8; font-family:var(--ui-font); font-size:10px; font-weight:850; letter-spacing:.04em; white-space:normal; overflow-wrap:anywhere; }
78
+ .relation-target { display:block; color:#51483f; font-weight:720; overflow-wrap:anywhere; }
79
+ .relation-note { display:block; margin-top:3px; color:var(--muted); overflow-wrap:anywhere; }
72
80
  .relation.inferred .relation-badge { background:#f4f0ea; border-color:var(--line); color:var(--muted); }
73
- .detail-panel { position:sticky; top:18px; max-height:calc(100vh - 36px); overflow:hidden; display:flex; flex-direction:column; }
81
+ .claim-panel,.detail-panel { position:sticky; top:18px; max-height:calc(100vh - 36px); overflow:hidden; display:flex; flex-direction:column; }
74
82
  .detail-head { padding:20px 20px 14px; border-bottom:1px solid var(--line); }
75
- .detail-title { margin:7px 0 0; font-size:22px; line-height:1.12; letter-spacing:-.035em; }
76
- .detail-sub { margin-top:8px; color:var(--muted); font-size:13px; line-height:1.4; }
83
+ .detail-title { margin:7px 0 0; font-size:24px; line-height:1.12; letter-spacing:-.035em; }
84
+ .detail-sub { margin-top:8px; color:var(--muted); font-size:15px; line-height:1.4; }
77
85
  .detail-body { padding:16px 20px 22px; overflow:auto; }
78
- .detail-card { border:1px solid var(--line); border-radius:16px; padding:13px; background:#fff; margin-bottom:10px; }
79
- .detail-card h3 { margin:0 0 8px; font-size:13px; letter-spacing:-.01em; }
80
- .detail-card p { margin:0; color:var(--muted); line-height:1.45; font-size:13px; }
86
+ .detail-card { min-width:0; border:0; border-left:5px solid var(--line); border-radius:0; padding:13px; background:transparent; margin-bottom:10px; box-shadow:none; font-family:var(--reading-font); }
87
+ .detail-card h3 { margin:0 0 8px; font-family:var(--ui-font); font-size:13px; letter-spacing:-.01em; }
88
+ .detail-card p { margin:0; color:var(--muted); font-family:var(--reading-font); line-height:1.45; font-size:15px; overflow-wrap:anywhere; }
89
+ .evidence-list { display:grid; gap:10px; min-width:0; }
90
+ .evidence-group-title { margin:12px 0 7px; color:var(--accent); font-family:var(--ui-font); font-size:11px; font-weight:900; letter-spacing:.09em; text-transform:uppercase; }
91
+ .evidence-item { width:100%; min-width:0; text-align:left; cursor:pointer; border:0; border-left:5px solid var(--good); border-radius:0; background:transparent; padding:14px; color:var(--ink); box-shadow:none; font-family:var(--reading-font); overflow-wrap:anywhere; word-break:break-word; transition:none; }
92
+ .evidence-item:hover .evidence-title,.evidence-item.active .evidence-title,.evidence-item:hover .evidence-source,.evidence-item.active .evidence-source { color:var(--accent); }
93
+ .evidence-item.strong { border-left-color:var(--good); }
94
+ .evidence-item.partial,.evidence-item.weak { border-left-color:var(--warn); }
95
+ .evidence-item.gap { border-left-color:var(--gap); background:transparent; }
96
+ .evidence-kind { display:inline-flex; width:fit-content; margin-bottom:9px; border-radius:999px; padding:3px 8px; font-family:var(--ui-font); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; background:#e8f4ed; color:var(--good); }
97
+ .evidence-item.partial .evidence-kind,.evidence-item.weak .evidence-kind { background:#fff1dc; color:var(--warn); }
98
+ .evidence-item.gap .evidence-kind { background:#eee6ff; color:var(--gap); }
99
+ .evidence-title { display:block; min-width:0; font-family:var(--reading-font); font-size:20px; font-weight:850; line-height:1.18; letter-spacing:-.012em; overflow-wrap:anywhere; word-break:break-word; }
100
+ .evidence-source { display:block; min-width:0; font-family:var(--reading-font); font-size:20px; font-weight:850; line-height:1.18; letter-spacing:-.012em; overflow-wrap:anywhere; word-break:break-word; }
101
+ .evidence-preview { display:block; margin-top:7px; color:var(--muted); font-size:15px; line-height:1.4; }
102
+ .evidence-field { display:block; min-width:0; margin-top:12px; }
103
+ .evidence-field-title { display:block; margin:0 0 4px; color:var(--accent); font-family:var(--ui-font); font-size:11px; font-weight:900; letter-spacing:.1em; text-transform:uppercase; }
104
+ .evidence-bullets { min-width:0; margin:10px 0 0; padding-left:18px; color:#51483f; font-family:var(--reading-font); font-size:15px; line-height:1.45; overflow-wrap:anywhere; word-break:break-word; }
105
+ .evidence-bullets li { margin:5px 0; }
106
+ .evidence-bullets strong { color:var(--accent); font-family:var(--ui-font); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; }
107
+ .evidence-why { display:block; margin-top:10px; padding-top:10px; border-top:1px solid #eee4d8; color:#51483f; font-size:15px; line-height:1.43; }
108
+ .evidence-why-label,.evidence-source-label { display:block; margin-bottom:3px; color:var(--accent); font-family:var(--ui-font); font-size:10px; font-weight:900; letter-spacing:.08em; text-transform:uppercase; }
109
+ .evidence-source-line { display:block; margin-top:10px; color:var(--muted); font-family:var(--ui-font); font-size:12px; line-height:1.35; overflow-wrap:anywhere; }
110
+ .evidence-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:9px; min-width:0; }
81
111
  .empty { color:var(--muted); font-style:italic; }
82
112
  .hidden-detail { display:none; }
83
- @media (max-width:1100px) { .layout { grid-template-columns:1fr; } .detail-panel { position:static; max-height:none; } .topbar { grid-template-columns:1fr; } .pills { justify-content:flex-start; } }
113
+ @media (max-width:1180px) { .layout { grid-template-columns:1fr; } .claim-panel,.detail-panel { position:static; max-height:none; } .topbar { grid-template-columns:1fr; } .pills { justify-content:flex-start; } }
84
114
  @media (max-width:680px) { .shell { padding:12px; } .topbar,.flow,.detail-panel { border-radius:18px; } .claim-step { grid-template-columns:30px minmax(0,1fr); gap:10px; } .step-dot { width:26px; height:26px; font-size:11px; } .claim-title { font-size:16px; } }
85
115
  </style>
86
116
  </head>
@@ -97,7 +127,6 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
97
127
  <span class="pill">${escapeHtml(display.labels.status)}: ${escapeHtml(localizeValue(map.snapshot.status, display))}</span>
98
128
  <span class="pill supported">${escapeHtml(systemTerm("claims", display))}: ${nodes.length}</span>
99
129
  <span class="pill ${inferredCount > 0 ? "open" : "current"}">${escapeHtml(systemTerm("relations", display))}: ${map.claimRelations.length}${inferredCount > 0 ? ` (${inferredCount} ${escapeHtml(systemTerm("inferred", display))})` : ""}</span>
100
- <span class="pill ${nonCurrentArtifacts > 0 ? "partial" : "current"}">${escapeHtml(systemTerm("artifacts", display))}: ${map.artifactCoverage.length}${nonCurrentArtifacts > 0 ? ` (${nonCurrentArtifacts} ${escapeHtml(systemTerm("attention", display))})` : ""}</span>
101
130
  </div>
102
131
  </header>
103
132
  <div class="layout">
@@ -112,32 +141,69 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
112
141
  ${nodes.length ? nodes.map((node, index) => renderStep(node, map, display, index, index === 0)).join("") : emptyCard(display.labels.claimFlow, display.labels.noClaims)}
113
142
  </div>
114
143
  </section>
115
- <aside class="detail-panel">
144
+ <aside class="claim-panel selected-claim">
116
145
  <div class="detail-head">
117
146
  <p class="eyebrow">${escapeHtml(display.labels.selectedClaim)}</p>
118
- <h2 class="detail-title" id="detail-title">${escapeHtml(initial?.title ?? display.labels.noClaims)}</h2>
119
- <div class="detail-sub" id="detail-sub">${escapeHtml(initial ? claimSubtitle(initial.claim, display) : "Run /revela init to create narrative claims.")}</div>
147
+ <h2 class="detail-title" id="detail-title">${escapeHtml(initial?.claimPanelTitle ?? display.labels.noClaims)}</h2>
148
+ <div class="detail-sub" id="detail-sub">${escapeHtml(initial?.claimPanelSubtitle ?? "Run /revela init to create narrative claims.")}</div>
120
149
  </div>
121
- <div class="detail-body" id="detail-body">${initial?.detailHtml ?? emptyCard(display.labels.claimFlow, display.labels.noClaims)}</div>
150
+ <div class="detail-body" id="claim-body">${initial?.claimHtml ?? emptyCard(display.labels.claimFlow, display.labels.noClaims)}</div>
151
+ </aside>
152
+ <aside class="detail-panel selected-evidence">
153
+ <div class="detail-head">
154
+ <p class="eyebrow">${escapeHtml(display.labels.selectedEvidence)}</p>
155
+ <h2 class="detail-title" id="evidence-title">${escapeHtml(initial?.initialDetailTitle ?? display.labels.selectedEvidence)}</h2>
156
+ <div class="detail-sub" id="evidence-sub">${escapeHtml(initial?.initialDetailSubtitle ?? display.labels.selectEvidencePrompt)}</div>
157
+ </div>
158
+ <div class="detail-body" id="evidence-body">${initial?.initialDetailHtml ?? emptyCard(display.labels.selectedEvidence, display.labels.selectEvidencePrompt)}</div>
122
159
  </aside>
123
160
  </div>
124
161
  </main>
125
162
  <div class="hidden-detail">
126
- ${nodes.map((node) => `<template id="detail-${escapeAttr(node.id)}" data-title="${escapeHtml(node.title)}" data-subtitle="${escapeHtml(claimSubtitle(node.claim, display))}">${node.detailHtml}</template>`).join("")}
163
+ ${nodes.map((node) => `<template id="claim-panel-${escapeAttr(node.id)}" data-title="${escapeHtml(node.claimPanelTitle)}" data-subtitle="${escapeHtml(node.claimPanelSubtitle)}" data-initial-detail-id="${escapeAttr(node.initialDetailId ?? "")}">${node.claimHtml}</template>${node.detailTemplates}`).join("")}
127
164
  </div>
128
165
  <script>
129
166
  const buttons = Array.from(document.querySelectorAll('.claim-card'));
130
167
  const title = document.getElementById('detail-title');
131
168
  const sub = document.getElementById('detail-sub');
132
- const body = document.getElementById('detail-body');
169
+ const claimBody = document.getElementById('claim-body');
170
+ const evidenceTitle = document.getElementById('evidence-title');
171
+ const evidenceSub = document.getElementById('evidence-sub');
172
+ const evidenceBody = document.getElementById('evidence-body');
173
+ function bindEvidenceItems() {
174
+ const items = Array.from(claimBody.querySelectorAll('.evidence-item'));
175
+ items.forEach((item) => item.addEventListener('click', () => selectDetail(item.dataset.detailId)));
176
+ }
177
+ function bindDetailItems() {
178
+ const items = Array.from(evidenceBody.querySelectorAll('.evidence-item'));
179
+ items.forEach((item) => item.addEventListener('click', () => selectDetail(item.dataset.detailId)));
180
+ }
181
+ function selectDetail(id) {
182
+ if (!id) return;
183
+ const template = document.getElementById('detail-item-' + CSS.escape(id));
184
+ if (!template) return;
185
+ evidenceTitle.textContent = template.dataset.title || '';
186
+ evidenceSub.textContent = template.dataset.subtitle || '';
187
+ evidenceBody.innerHTML = template.innerHTML;
188
+ Array.from(claimBody.querySelectorAll('.evidence-item')).forEach((item) => item.classList.toggle('active', item.dataset.detailId === id));
189
+ bindDetailItems();
190
+ }
133
191
  function selectClaim(id) {
134
- const template = document.getElementById('detail-' + CSS.escape(id));
192
+ const template = document.getElementById('claim-panel-' + CSS.escape(id));
135
193
  if (!template) return;
136
194
  title.textContent = template.dataset.title || '';
137
195
  sub.textContent = template.dataset.subtitle || '';
138
- body.innerHTML = template.innerHTML;
196
+ claimBody.innerHTML = template.innerHTML;
139
197
  buttons.forEach((button) => button.classList.toggle('active', button.dataset.nodeId === id));
198
+ bindEvidenceItems();
199
+ if (template.dataset.initialDetailId) selectDetail(template.dataset.initialDetailId);
200
+ else {
201
+ evidenceTitle.textContent = '${escapeJs(display.labels.selectedEvidence)}';
202
+ evidenceSub.textContent = '${escapeJs(display.labels.selectEvidencePrompt)}';
203
+ evidenceBody.innerHTML = '${escapeJs(emptyCard(display.labels.selectedEvidence, display.labels.selectEvidencePrompt))}';
204
+ }
140
205
  }
206
+ bindEvidenceItems();
141
207
  buttons.forEach((button) => button.addEventListener('click', () => selectClaim(button.dataset.nodeId)));
142
208
  </script>
143
209
  </body>
@@ -145,13 +211,23 @@ export function renderNarrativeMapHtmlWithDisplay(map: NarrativeMap, display: Va
145
211
  }
146
212
 
147
213
  function buildFlowNodes(map: NarrativeMap, display: ValidatedNarrativeDisplayModel): FlowNode[] {
148
- return allClaims(map).map((claim) => ({
149
- id: nodeId(claim.id),
150
- claim,
151
- title: display.claimCards.get(claim.id)?.displayTitle ?? claim.text,
152
- displayCard: display.claimCards.get(claim.id),
153
- detailHtml: claimDetail(claim, map, display),
154
- }))
214
+ return allClaims(map).map((claim) => {
215
+ const panel = claimEvidencePanel(claim, map, display)
216
+ return {
217
+ id: nodeId(claim.id),
218
+ claim,
219
+ title: display.claimCards.get(claim.id)?.displayTitle ?? claim.text,
220
+ displayCard: display.claimCards.get(claim.id),
221
+ claimHtml: panel.html,
222
+ claimPanelTitle: panel.title,
223
+ claimPanelSubtitle: panel.subtitle,
224
+ initialDetailId: panel.initialDetailId,
225
+ initialDetailHtml: panel.initialDetailHtml,
226
+ initialDetailTitle: panel.initialDetailTitle,
227
+ initialDetailSubtitle: panel.initialDetailSubtitle,
228
+ detailTemplates: panel.detailTemplates,
229
+ }
230
+ })
155
231
  }
156
232
 
157
233
  function renderStep(node: FlowNode, map: NarrativeMap, display: ValidatedNarrativeDisplayModel, index: number, active: boolean): string {
@@ -174,7 +250,6 @@ function renderDisplayCardSummary(card: FlowNode["displayCard"], display: Valida
174
250
  [labels.role, card.roleLabel],
175
251
  [labels.narrativeJob, card.narrativeJob],
176
252
  [labels.evidenceSummary, card.evidenceSummary],
177
- [labels.riskOrGapSummary, card.riskOrGapSummary],
178
253
  ]
179
254
  if (rows.length === 0) return ""
180
255
  return `<span class="claim-sections">${rows.filter(([, value]) => Boolean(value)).map(([label, value]) => `<span class="claim-section"><span class="section-label">${escapeHtml(label)}</span><span class="section-text">${escapeHtml(value)}</span></span>`).join("")}</span>`
@@ -187,52 +262,125 @@ function renderOutgoingRelation(relation: NarrativeMapClaimRelation, display: Va
187
262
  return `<span class="relation${relation.inferred ? " inferred" : ""}"><span class="relation-badge">${escapeHtml(label)}</span><span><span class="relation-target">${escapeHtml(systemTerm("to", display))}: ${escapeHtml(shorten(target, 120))}</span>${rationale ? `<span class="relation-note">${escapeHtml(systemTerm("rationale", display))}: ${escapeHtml(rationale)}</span>` : ""}</span></span>`
188
263
  }
189
264
 
190
- function claimDetail(claim: NarrativeMapClaim, map: NarrativeMap, display: ValidatedNarrativeDisplayModel): string {
191
- const incoming = map.claimRelations.filter((relation) => relation.toClaimId === claim.id)
192
- const outgoing = map.claimRelations.filter((relation) => relation.fromClaimId === claim.id)
193
- const objections = map.objections.filter((item) => item.claimId === claim.id)
194
- const risks = map.risks.filter((item) => item.claimId === claim.id)
195
- const gaps = map.researchGaps.filter((item) => item.targetType === "claim" && item.targetId === claim.id)
196
- const slideRefs = map.artifactCoverage.flatMap((artifact) => artifact.slideRefs.filter((ref) => ref.claimId === claim.id).map((ref) => `${artifact.type} slide ${ref.slideIndex} (${ref.role}, ${ref.match}/${ref.location}, coverage:${artifact.coverageStatus})`))
197
- const coverageGaps = map.artifactCoverage.filter((artifact) => artifact.missingClaimIds.includes(claim.id) || artifact.affectedClaimIds.includes(claim.id))
265
+ function claimEvidencePanel(claim: NarrativeMapClaim, map: NarrativeMap, display: ValidatedNarrativeDisplayModel): { title: string; subtitle: string; html: string; initialDetailId?: string; initialDetailHtml: string; initialDetailTitle: string; initialDetailSubtitle: string; detailTemplates: string } {
266
+ const gaps = relatedGaps(claim, map)
198
267
  const card = display.claimCards.get(claim.id)
268
+ const evidenceItems = claim.evidence.map((evidence) => ({
269
+ id: itemDomId("evidence", evidence.id),
270
+ kind: "evidence" as const,
271
+ title: display.labels.linkedGaps,
272
+ subtitle: evidence.source || evidence.id,
273
+ html: linkedGapsPanel(evidence, gaps.filter((gap) => gap.evidenceBindingIds?.includes(evidence.id)), display),
274
+ card: evidenceCard(evidence, display, card),
275
+ }))
276
+ const gapItems = gaps.map((gap) => ({
277
+ id: itemDomId("gap", gap.id),
278
+ kind: "gap" as const,
279
+ title: display.labels.selectedGap,
280
+ subtitle: `${localizeValue(gap.status, display)} / ${localizeValue(gap.priority, display)}`,
281
+ html: gapDetail(gap, display),
282
+ card: gapCard(gap, display),
283
+ }))
284
+ const items = [...evidenceItems, ...gapItems]
285
+ const initial = items[0]
286
+ const title = gaps.length ? `${display.labels.evidenceList} / ${display.labels.gaps}` : display.labels.evidenceList
287
+ const statusLine = [`${claim.evidence.length} ${display.labels.evidenceList}`, gaps.length ? `${gaps.length} ${display.labels.gaps}` : undefined].filter(Boolean).join(" / ")
288
+ const html = [
289
+ `<div class="evidence-list">`,
290
+ claim.evidence.length ? `<div class="evidence-group-title">${escapeHtml(display.labels.evidenceList)}</div>${evidenceItems.map((item) => item.card).join("")}` : `<div class="detail-card"><h3>${escapeHtml(display.labels.evidenceList)}</h3><p class="empty">${escapeHtml(display.labels.noEvidence)}</p></div>`,
291
+ gaps.length ? `<div class="evidence-group-title">${escapeHtml(display.labels.gaps)}</div>${gapItems.map((item) => item.card).join("")}` : "",
292
+ `</div>`,
293
+ ].join("")
294
+ return {
295
+ title,
296
+ subtitle: statusLine || display.labels.selectEvidencePrompt,
297
+ html,
298
+ initialDetailId: initial?.id,
299
+ initialDetailHtml: initial?.html ?? emptyCard(display.labels.selectedEvidence, display.labels.selectEvidencePrompt),
300
+ initialDetailTitle: initial?.title ?? display.labels.selectedEvidence,
301
+ initialDetailSubtitle: initial?.subtitle ?? display.labels.selectEvidencePrompt,
302
+ detailTemplates: items.map((item) => `<template id="detail-item-${escapeAttr(item.id)}" data-title="${escapeHtml(item.title)}" data-subtitle="${escapeHtml(item.subtitle)}">${item.html}</template>`).join(""),
303
+ }
304
+ }
305
+
306
+ function evidenceCard(evidence: NarrativeMapClaim["evidence"][number], display: ValidatedNarrativeDisplayModel, card: ReturnType<ValidatedNarrativeDisplayModel["claimCards"]["get"]>): string {
307
+ const id = itemDomId("evidence", evidence.id)
308
+ const description = evidence.quote || evidence.source || evidence.location || evidence.supportScope || display.labels.evidence
309
+ const why = card?.supportRationale || card?.supportedScope || evidence.supportScope
310
+ const sources = sourceItems(evidence, display)
311
+ return `<button class="evidence-item ${escapeAttr(evidence.strength)}" type="button" data-evidence-id="${escapeAttr(evidence.id)}" data-detail-id="${escapeAttr(id)}">
312
+ <span class="evidence-kind">${escapeHtml(display.labels.evidence)}</span>
313
+ <span class="evidence-title">${escapeHtml(shorten(description, 180))}</span>
314
+ ${why ? `<span class="evidence-field"><span class="evidence-field-title">${escapeHtml(display.labels.whyThisSupports)}</span><ul class="evidence-bullets"><li>${escapeHtml(shorten(why, 220))}</li></ul></span>` : ""}
315
+ <span class="evidence-field"><span class="evidence-field-title">${escapeHtml(display.labels.evidenceSource)}</span><ul class="evidence-bullets">${sources.map((source) => `<li>${escapeHtml(source)}</li>`).join("")}</ul></span>
316
+ <span class="evidence-meta"><span class="tag">${escapeHtml(localizeValue(evidence.strength, display))}</span>${evidence.location ? `<span class="tag">${escapeHtml(evidence.location)}</span>` : ""}${evidence.findingsFile ? `<span class="tag">${escapeHtml(evidence.findingsFile)}</span>` : ""}</span>
317
+ </button>`
318
+ }
319
+
320
+ function gapCard(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string {
321
+ const id = itemDomId("gap", gap.id)
322
+ const question = displayGapQuestion(gap, display)
323
+ return `<button class="evidence-item gap" type="button" data-gap-id="${escapeAttr(gap.id)}" data-detail-id="${escapeAttr(id)}">
324
+ <span class="evidence-kind">${escapeHtml(display.labels.gap)}</span>
325
+ <span class="evidence-source">${escapeHtml(question)}</span>
326
+ <ul class="evidence-bullets"><li><strong>${escapeHtml(display.labels.status)}</strong> ${escapeHtml(localizeValue(gap.status, display))}</li><li><strong>${escapeHtml(systemTerm("priority", display))}</strong> ${escapeHtml(localizeValue(gap.priority, display))}</li></ul>
327
+ <span class="evidence-meta"><span class="tag">${escapeHtml(localizeValue(gap.status, display))}</span><span class="tag">${escapeHtml(localizeValue(gap.priority, display))}</span></span>
328
+ </button>`
329
+ }
330
+
331
+ function linkedGapsPanel(evidence: NarrativeMapClaim["evidence"][number], gaps: NarrativeMapResearchGap[], display: ValidatedNarrativeDisplayModel): string {
332
+ if (!gaps.length) return emptyCard(display.labels.linkedGaps, display.labels.noLinkedGaps)
333
+ return `<div class="evidence-list">${gaps.map((gap) => gapCard(gap, display)).join("")}</div>`
334
+ }
335
+
336
+ function gapDetail(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string {
199
337
  return detailCards([
200
- [display.labels.claim, displayClaimText(claim.id, claim.text, display)],
201
- ...(card?.narrativeJob ? [[card.roleLabel || display.labels.claim, card.narrativeJob] as [string, string]] : []),
202
- ...(card?.evidenceSummary ? [[display.labels.evidence, card.evidenceSummary] as [string, string]] : []),
203
- ...(card?.supportRationale ? [[systemTerm("supportRationale", display), card.supportRationale] as [string, string]] : []),
204
- ...(card?.riskOrGapSummary ? [[display.labels.researchGaps, card.riskOrGapSummary] as [string, string]] : []),
205
- [display.labels.claimId, claim.id],
206
- [display.labels.status, `${localizeValue(claim.evidenceStatus, display)} / ${localizeValue(claim.importance, display)} / ${localizeValue(claim.kind, display)}`],
207
- ...localizedOptionalRow(display.labels.supportedScope, card?.supportedScope, claim.supportedScope, display),
208
- ...localizedOptionalRow(display.labels.unsupportedScope, card?.unsupportedScope, claim.unsupportedScope, display),
209
- [display.labels.incomingRelations, incoming.length ? incoming.map((relation) => relationText(relation, display)).join("<br><br>") : display.labels.none],
210
- [display.labels.outgoingRelations, outgoing.length ? outgoing.map((relation) => relationText(relation, display)).join("<br><br>") : display.labels.none],
211
- ...(claim.evidence.length ? claim.evidence.map((evidence) => [`${display.labels.evidence}: ${evidence.source}`, evidenceDetailText(evidence, display, card)] as [string, string]) : [[display.labels.evidence, display.labels.none] as [string, string]]),
212
- ...localizedOptionalRow(display.labels.objections, card?.objectionsSummary, objections.length ? objections.map((item) => `${item.text}${item.response ? ` -> ${item.response}` : ""}`).join("<br>") : undefined, display),
213
- ...localizedOptionalRow(display.labels.risks, card?.risksSummary, risks.length ? risks.map((item) => `${item.text}${item.mitigation ? ` -> ${item.mitigation}` : ""}`).join("<br>") : undefined, display),
214
- ...localizedOptionalRow(display.labels.researchGaps, card?.researchGapsSummary ?? card?.riskOrGapSummary, gaps.length ? gaps.map((item) => `${item.question} [${localizeValue(item.status, display)}/${localizeValue(item.priority, display)}]`).join("<br>") : undefined, display),
215
- ...(slideRefs.length ? [[display.labels.coveredSlides, slideRefs.map((ref) => localizeSlideRef(ref, display)).join("<br>")] as [string, string]] : []),
216
- ...(coverageGaps.length ? [[display.labels.coveredSlides, coverageGaps.map((artifact) => `${artifact.type}: ${localizeValue(artifact.coverageStatus, display)}${artifact.staleReasons.length ? ` - ${artifact.staleReasons.join("; ")}` : ""}`).join("<br>")] as [string, string]] : []),
338
+ [display.labels.gap, displayGapQuestion(gap, display)],
339
+ [display.labels.status, `${localizeValue(gap.status, display)} / ${localizeValue(gap.priority, display)}`],
340
+ [systemTerm("target", display), `${gap.targetType}${gap.targetId ? `: ${gap.targetId}` : ""}`],
341
+ ...optionalRows(systemTerm("findingsFile", display), gap.findingsFile),
342
+ ...optionalRows(systemTerm("evidenceBindingIds", display), gap.evidenceBindingIds?.join(", ")),
343
+ ...optionalRows(systemTerm("notes", display), gap.notes),
217
344
  ])
218
345
  }
219
346
 
220
- function localizedOptionalRow(label: string, localized: string | undefined, fallback: string | undefined, display: ValidatedNarrativeDisplayModel): Array<[string, string]> {
221
- if (localized) return [[label, localized]]
222
- if (!fallback || isLocalizedDisplay(display)) return []
223
- return [[label, fallback]]
347
+ function relatedGaps(claim: NarrativeMapClaim, map: NarrativeMap): NarrativeMapResearchGap[] {
348
+ const evidenceIds = new Set(claim.evidence.map((evidence) => evidence.id))
349
+ const seen = new Set<string>()
350
+ return map.researchGaps.filter((gap) => {
351
+ const matchesClaim = gap.targetType === "claim" && gap.targetId === claim.id
352
+ const matchesEvidence = (gap.evidenceBindingIds ?? []).some((id) => evidenceIds.has(id))
353
+ if ((!matchesClaim && !matchesEvidence) || seen.has(gap.id)) return false
354
+ seen.add(gap.id)
355
+ return true
356
+ })
224
357
  }
225
358
 
226
- function isLocalizedDisplay(display: ValidatedNarrativeDisplayModel): boolean {
227
- return display.language !== "en"
359
+ function optionalRows(label: string, value: string | undefined): Array<[string, string]> {
360
+ return value ? [[label, value]] : []
228
361
  }
229
362
 
230
- function relationText(relation: NarrativeMapClaimRelation, display: ValidatedNarrativeDisplayModel): string {
231
- const from = displayClaimText(relation.fromClaimId, relation.fromClaimText, display)
232
- const to = displayClaimText(relation.toClaimId, relation.toClaimText, display)
233
- const label = relationDisplayLabel(relation, display, false)
234
- const rationale = relationDisplayRationale(relation, display)
235
- return `${systemTerm("relation", display)}: ${label}${relation.inferred ? ` (${systemTerm("inferred", display)})` : ""}<br>${systemTerm("from", display)}: ${from}<br>${systemTerm("to", display)}: ${to}${rationale ? `<br>${systemTerm("rationale", display)}: ${rationale}` : ""}`
363
+ function sourceItems(evidence: NarrativeMapClaim["evidence"][number], display: ValidatedNarrativeDisplayModel): string[] {
364
+ const items = [evidence.source, evidence.location, evidence.findingsFile, evidence.sourcePath, ...splitSourceUrls(evidence.url)]
365
+ .map((value) => value?.trim())
366
+ .filter((value): value is string => Boolean(value))
367
+ const unique = Array.from(new Set(items))
368
+ return unique.length ? unique : [display.labels.none]
369
+ }
370
+
371
+ function splitSourceUrls(value: string | undefined): string[] {
372
+ if (!value?.trim()) return []
373
+ const urls = value.match(/https?:\/\/[^\s,;]+/g)
374
+ if (urls?.length) return urls
375
+ return value.split(/\s*[;·]\s*/).filter(Boolean)
376
+ }
377
+
378
+ function displayGapQuestion(gap: NarrativeMapResearchGap, display: ValidatedNarrativeDisplayModel): string {
379
+ return display.researchGapCards.get(gap.id)?.displayQuestion ?? gap.question
380
+ }
381
+
382
+ function isLocalizedDisplay(display: ValidatedNarrativeDisplayModel): boolean {
383
+ return display.language !== "en"
236
384
  }
237
385
 
238
386
  function displayClaimText(claimId: string, fallback: string | undefined, display: ValidatedNarrativeDisplayModel): string {
@@ -279,25 +427,10 @@ function emptyCard(label: string, value: string): string {
279
427
  return `<div class="detail-card"><h3>${escapeHtml(label)}</h3><p class="empty">${escapeHtml(value)}</p></div>`
280
428
  }
281
429
 
282
- function evidenceDetailText(evidence: NarrativeMapClaim["evidence"][number], display: ValidatedNarrativeDisplayModel, card: ReturnType<ValidatedNarrativeDisplayModel["claimCards"]["get"]>): string {
283
- return [
284
- `${systemTerm("strength", display)}: ${localizeValue(evidence.strength, display)}`,
285
- evidence.findingsFile ? `${systemTerm("findingsFile", display)}: ${evidence.findingsFile}` : "",
286
- evidence.location ? `${systemTerm("location", display)}: ${evidence.location}` : "",
287
- evidence.quote ? `${systemTerm("quote", display)}: ${evidence.quote}` : "",
288
- card?.unsupportedScope ? `${display.labels.unsupportedScope}: ${card.unsupportedScope}` : evidence.unsupportedScope && !isLocalizedDisplay(display) ? `${display.labels.unsupportedScope}: ${evidence.unsupportedScope}` : "",
289
- card?.supportRationale ? `${systemTerm("supportRationale", display)}: ${card.supportRationale}` : evidence.caveat && !isLocalizedDisplay(display) ? `${systemTerm("caveat", display)}: ${evidence.caveat}` : "",
290
- ].filter(Boolean).join(" | ")
291
- }
292
-
293
430
  function allClaims(map: NarrativeMap): NarrativeMapClaim[] {
294
431
  return map.claimFlow.length > 0 ? map.claimFlow : map.claims.supported.concat(map.claims.partial, map.claims.weak, map.claims.missing, map.claims.not_required)
295
432
  }
296
433
 
297
- function claimSubtitle(claim: NarrativeMapClaim, display: ValidatedNarrativeDisplayModel): string {
298
- return `${localizeValue(claim.kind, display)} / ${localizeValue(claim.importance, display)} / ${localizeValue(claim.evidenceStatus, display)}`
299
- }
300
-
301
434
  function sectionLabels(display: ValidatedNarrativeDisplayModel): Record<string, string> {
302
435
  if (isChineseLanguage(display.language)) return { role: "角色", narrativeJob: "叙事任务", evidenceSummary: "证据摘要", riskOrGapSummary: "风险/缺口" }
303
436
  if (isJapaneseLanguage(display.language)) return { role: "役割", narrativeJob: "ナラティブ上の役割", evidenceSummary: "根拠の要約", riskOrGapSummary: "リスク/ギャップ" }
@@ -305,9 +438,9 @@ function sectionLabels(display: ValidatedNarrativeDisplayModel): Record<string,
305
438
  }
306
439
 
307
440
  function systemTerm(term: string, display: ValidatedNarrativeDisplayModel): string {
308
- const zh: Record<string, string> = { approval: "审批", claims: "主张", relations: "关系", inferred: "未确认", relation: "关系", from: "来自", to: "指向", rationale: "说明", supportRationale: "支撑逻辑", strength: "强度", findingsFile: "研究文件", location: "位置", quote: "引用", caveat: "注意事项", artifacts: "产物", attention: "需关注", none: display.labels.none }
309
- const ja: Record<string, string> = { approval: "承認", claims: "クレーム", relations: "関係", inferred: "未確認", relation: "関係", from: "起点", to: "終点", rationale: "理由", supportRationale: "裏付けの論理", strength: "強度", findingsFile: "調査ファイル", location: "場所", quote: "引用", caveat: "留意点", artifacts: "成果物", attention: "要確認", none: display.labels.none }
310
- const en: Record<string, string> = { approval: "approval", claims: "claims", relations: "relations", inferred: "unconfirmed", relation: "relation", from: "from", to: "to", rationale: "rationale", supportRationale: "why this supports the claim", strength: "strength", findingsFile: "findings file", location: "location", quote: "quote", caveat: "caveat", artifacts: "artifacts", attention: "need attention", none: display.labels.none }
441
+ const zh: Record<string, string> = { approval: "审批", claims: "主张", relations: "关系", inferred: "未确认", relation: "关系", from: "来自", to: "指向", rationale: "说明", supportRationale: "支撑逻辑", strength: "强度", priority: "优先级", findingsFile: "研究文件", sourcePath: "来源文件", url: "链接", location: "位置", quote: "引用", caveat: "注意事项", target: "目标", evidenceBindingIds: "论据 ID", notes: "备注", artifacts: "产物", attention: "需关注", none: display.labels.none }
442
+ const ja: Record<string, string> = { approval: "承認", claims: "クレーム", relations: "関係", inferred: "未確認", relation: "関係", from: "起点", to: "終点", rationale: "理由", supportRationale: "裏付けの論理", strength: "強度", priority: "優先度", findingsFile: "調査ファイル", sourcePath: "出典ファイル", url: "URL", location: "場所", quote: "引用", caveat: "留意点", target: "対象", evidenceBindingIds: "根拠ID", notes: "メモ", artifacts: "成果物", attention: "要確認", none: display.labels.none }
443
+ const en: Record<string, string> = { approval: "approval", claims: "claims", relations: "relations", inferred: "unconfirmed", relation: "relation", from: "from", to: "to", rationale: "rationale", supportRationale: "why this supports the claim", strength: "strength", priority: "priority", findingsFile: "findings file", sourcePath: "source path", url: "URL", location: "location", quote: "quote", caveat: "caveat", target: "target", evidenceBindingIds: "evidence binding IDs", notes: "notes", artifacts: "artifacts", attention: "need attention", none: display.labels.none }
311
444
  return (isChineseLanguage(display.language) ? zh : isJapaneseLanguage(display.language) ? ja : en)[term] ?? term
312
445
  }
313
446
 
@@ -329,16 +462,14 @@ function localizeValue(value: string, display: ValidatedNarrativeDisplayModel):
329
462
  return table[value] ?? value
330
463
  }
331
464
 
332
- function localizeSlideRef(value: string, display: ValidatedNarrativeDisplayModel): string {
333
- if (isChineseLanguage(display.language)) return value.replace(/slide/g, "页面")
334
- if (isJapaneseLanguage(display.language)) return value.replace(/slide/g, "スライド")
335
- return value
336
- }
337
-
338
465
  function nodeId(id: string): string {
339
466
  return `claim-${id}`.replace(/[^a-zA-Z0-9._-]+/g, "-")
340
467
  }
341
468
 
469
+ function itemDomId(prefix: string, id: string): string {
470
+ return `${prefix}-${id}`.replace(/[^a-zA-Z0-9._-]+/g, "-")
471
+ }
472
+
342
473
  function shorten(value: string | undefined, max: number): string {
343
474
  const text = valueOrFallback(value, "-")
344
475
  return text.length > max ? `${text.slice(0, max - 1)}...` : text
@@ -359,3 +490,7 @@ function escapeHtml(value: string | undefined): string {
359
490
  function escapeAttr(value: string | undefined): string {
360
491
  return escapeHtml(value).replace(/[^a-zA-Z0-9_-]/g, "_")
361
492
  }
493
+
494
+ function escapeJs(value: string | undefined): string {
495
+ return escapeHtml(value).replace(/[\\'`$]/g, (ch) => ({ "\\": "\\\\", "'": "\\'", "`": "\\`", "$": "\\$" }[ch] ?? ch))
496
+ }