misakanet 2.44.1 → 2.45.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.
package/lib/client.js CHANGED
@@ -27,12 +27,14 @@
27
27
  * screen, and the only surface from which a human can speak about it.
28
28
  *
29
29
  * Why the vote is worth this much care: `POST /api/helpful` is the **only** writer of the counter that
30
- * `misakanet_me_events` reports as `lesson_found_helpful`, so one click is what turns a private success
31
- * into reuse evidence every later agent can read (the event appears at one vote, `E4` at two). Three
32
- * rules follow, and they explain several odd-looking details below:
30
+ * `misakanet_me_events` reports as `lesson_found_helpful`. That event is a *hint*, not the rung: since
31
+ * 2026-10-16 a vote is capped below `E4` at every count (measured then: 3 of the 24 most-retrieved
32
+ * lessons carried any vote, 6 votes in total, 0 of 483 lessons at `E4`), because the person shown the
33
+ * button did not ask the question, and the reuse the rung is about is observable at the transport
34
+ * layer instead. Three rules follow, and they explain several odd-looking details below:
33
35
  *
34
36
  * 1. **Never vote on the human's behalf.** No auto-vote on a successful build, no default selection,
35
- * no pre-checked box: `E4` is worth something only because a person said it.
37
+ * no pre-checked box: a vote means something only because a person chose to give it.
36
38
  * 2. **Abstention is free and is the default.** Doing nothing is a first-class outcome — the buttons
37
39
  * appear, and nothing nags if they are ignored.
38
40
  * 3. **Say what each action sends.** 👍 posts the lesson id and nothing else. 👎 has to carry the search
@@ -126,17 +128,20 @@ window.__ModuleLoader__.load({
126
128
  var en = {
127
129
  "guide.description": "What this session asked, what came back, and what you filed",
128
130
  "panel.asked": "What this session asked",
129
- "panel.asked.none": "No MisakaNet search has run in this session yet.",
131
+ "panel.asked.none": "No MisakaNet search has run in this session yet — the entry point is the composer: type /misakanet, then what broke.",
130
132
  "panel.reports": "Reports you filed",
131
133
  "panel.reports.none": "Nothing filed from this session.",
132
134
  "panel.trust": "How much these lessons are trusted",
133
- "panel.trust.none": "No lesson surfaced yet.",
135
+ "panel.trust.none": "No lesson surfaced yet — the entry point is the composer: type /misakanet, then what broke.",
136
+ "panel.hint.open": "Open the search box over the composer",
137
+ "panel.hint.openLabel": "Open the search box",
138
+ "panel.hint.openTitle": "Opens the composer's own MisakaNet overlay, which carries the same instruction. It does not type into the composer: a view occupant receives props, and the draft is reachable only from a live session scope, so nothing here can put `/misakanet ` in the box for you.",
134
139
  "panel.activity": "Your activity",
135
140
  "panel.voice": "Voice",
136
141
  "panel.voice.on": "Voice cues: on",
137
142
  "panel.voice.off": "Voice cues: off",
138
143
  "overlay.searching": "Searching…",
139
- "overlay.empty": "Type what broke after /misakanet, e.g. /misakanet pip install timeout",
144
+ "overlay.empty": "After /misakanet, type what broke, e.g. /misakanet pip install timeout",
140
145
  "overlay.none": "No lesson covers this yet. Ask the agent to file it (misakanet_submit_intake), or file it at misakanet.org — that is how the next person finds it here.",
141
146
  "overlay.failed": "Search failed: {error} — the library is at misakanet.org; nothing was sent but the query.",
142
147
  "overlay.footnote": "Reads are anonymous. This overlay sends only the query to misakanet.org; your votes and reports are the only other things a page ever sends.",
@@ -169,11 +174,15 @@ window.__ModuleLoader__.load({
169
174
  "unit.votes": "votes",
170
175
  "unit.report": "report",
171
176
  "unit.reports": "reports",
177
+ "unit.reportFiled": "report filed",
178
+ "unit.reportsFiled": "reports filed",
179
+ "unit.retrieval": "retrieval",
180
+ "unit.retrievals": "retrievals",
172
181
  "panel.asked.searching": "searching…",
173
182
  "panel.asked.noMatch": "no lesson matched",
174
183
  "panel.asked.top": "· top: ",
175
184
  "panel.asked.noQuery": "(no query text)",
176
- "panel.asked.nothingFound": "nothing found",
185
+ "panel.asked.nothingFound": "found nothing",
177
186
  "panel.asked.voteHelpful": "Helpful",
178
187
  "panel.asked.voteNotHelpful": "not helpful",
179
188
  "panel.asked.voteNone": "not voted",
@@ -181,13 +190,35 @@ window.__ModuleLoader__.load({
181
190
  "panel.asked.voteHint": "Same action as the verdict beside the assistant message, without scrolling back to it. Sends this lesson's id and one constant label (`dsh-web`); nothing else.",
182
191
  "panel.asked.voteFailed": "could not reach misakanet.org — nothing was recorded",
183
192
  "panel.asked.voteOne": "1 human confirmation — the reuse event now appears (E3: a reuse signal, not CI verification).",
184
- "panel.asked.voteTwo": "2 human confirmations — enough for agents to read this as E4.",
185
- "panel.asked.voteMore": "human confirmations — enough for agents to read this as E4.",
193
+ "panel.asked.voteTwo": "2 human confirmations — recorded as a hint; the E4 rung comes from observable reuse, not a vote.",
194
+ "panel.asked.voteMore": "human confirmations — recorded as a hint; the E4 rung comes from observable reuse, not a vote.",
186
195
  "panel.asked.noMatchTag": "no match",
187
196
  "panel.asked.agentNext": "Hand this line to the agent:",
188
197
  "panel.asked.agentNextCopy": "Copy the next step",
189
198
  "panel.asked.agentNextText": "Please record this gap with the MisakaNet intake so the next person finds it: misakanet_submit_intake problem: ",
190
199
  "panel.asked.agentNextWhy": "This page cannot file it: a report is re-checked by submitting the same text again, the server's dedup window is finite, and a page-triggered submit could open a second issue for one problem.",
200
+ // The retrieval ranking is the network's own observation, not this session's, and it is not a
201
+ // judgment. Every word is a lookup for the same reason the counters are — a zh reader has to get
202
+ // zh — and the one thing none of these strings may say is that the order is approval: the count
203
+ // is how often a search put that lesson first, which says nothing about whether it helped.
204
+ "panel.hot": "What the corpus returns most",
205
+ "panel.hot.loading": "reading the network's retrieval ranking…",
206
+ "panel.hot.none": "The analytics endpoint ranked no lesson, so this section shows nothing rather than a zero.",
207
+ "panel.hot.rule": "Each number is how many times a search ranked that lesson as its top hit — a retrieval count from the network's own analytics. It is an observed count, not a vote, not an approval, and not evidence that the lesson helped.",
208
+ "panel.hot.limit": "The {n} most-retrieved lessons — the endpoint returns more, so this list stops at {n}.",
209
+ "panel.hot.noCount": "the endpoint did not report a count",
210
+ // The coverage read answers the *other* half of the same question, and it must not read as a
211
+ // second version of the ranking above: section ⑥ counts what searches brought back, this counts
212
+ // what they did not find. Every visible word is a lookup for the same reason the counters are,
213
+ // and the one thing none of these strings may say is that the order is an opinion — it is a
214
+ // count of unanswered searches.
215
+ "panel.coverage": "The topics the corpus cannot answer yet",
216
+ "panel.coverage.loading": "reading the network's coverage board…",
217
+ "panel.coverage.none": "The coverage board ranked no topic, so this section shows nothing rather than a guess.",
218
+ "panel.coverage.rule": "The ranking above counts what searches brought back; this one counts what they did not find. Each row is a topic with the last 30 days' unanswered searches, read from the network's own coverage board, and the number of published lessons that match it.",
219
+ "panel.coverage.lessonsTpl": "{count} in the corpus · ",
220
+ "panel.coverage.unsolvedTpl": "{count} found nothing in 30 days",
221
+ "panel.coverage.totals": "{count} published in all · the topics below are the ones with the most unanswered searches",
191
222
  "panel.counters": "this session · counters local to this browser · no account, nothing uploaded but your votes and reports",
192
223
  "panel.report.title": "MisakaNet report",
193
224
  "panel.report.coveredBy": "covered by ",
@@ -208,12 +239,9 @@ window.__ModuleLoader__.load({
208
239
  "panel.trust.used": " · used ",
209
240
  "panel.trust.external": "external reuse evidence: ",
210
241
  "panel.trust.declared": "declared: ",
211
- "panel.trust.e4signal": "live signal: E4",
212
- "panel.trust.footnote": "The level is the lesson's own stored evidence. The count is other people's confirmations, not yours: one makes the reuse event appear, the second is what agents read as E4.",
242
+ "panel.trust.footnote": "The level is the lesson's own stored evidence; the count is other people's votes, not yours — recorded as a hint, never as proof. Agents read E4 only from observable reuse, which a vote cannot confer.",
213
243
  "panel.activity.session": "this session: ",
214
244
  "panel.activity.browser": "on this browser: ",
215
- "panel.activity.reused": " reused · ",
216
- "panel.activity.filed": " filed · ",
217
245
  "panel.activity.conversion": "conversion",
218
246
  "panel.activity.scope": "The browser column survives a reload; the session column is rebuilt from this conversation's transcript and starts again in a new session.",
219
247
  "panel.voice.cue": "The cue is the server's own choice for the last search (",
@@ -221,7 +249,7 @@ window.__ModuleLoader__.load({
221
249
  "panel.voice.none": "No cue yet: the server names one on the first search of the session.",
222
250
  "panel.voice.play": "Play “",
223
251
  "panel.voice.hook": "The local hook is a different thing: it is installed with `--voice` and mutes with MISAKANET_VOICE=0, plays through this machine's own player, and can also raise a desktop notification. A page cannot read or change it, so this panel does not report its state.",
224
- "panel.footnote": "Every number above comes from this conversation's own transcript rows plus one public GET per lesson (/api/helpful). The trust counts are other people's confirmations, not yours. Reloading the window loses the session rows; the browser counters stay. Receipts shown here are what the agent's own calls returned — a pending report is re-checked when the agent asks again, not by this page.",
252
+ "panel.footnote": "Every number above comes from this conversation's own transcript rows plus one public GET per lesson (/api/helpful), one read of the network's retrieval ranking (/api/analytics) and one read of its coverage board (/api/insights/lesson-coverage). The trust counts are other people's confirmations, not yours. Reloading the window loses the session rows; the browser counters stay. Receipts shown here are what the agent's own calls returned — a pending report is re-checked when the agent asks again, not by this page.",
225
253
  "main.footnote": "The left column opens this page; the session-scoped counters live in the session's right-sidebar MisakaNet tab. The numbers here are what this browser has contributed.",
226
254
  "tool.rawResult": "raw result",
227
255
  "tool.readFailed": "result could not be read",
@@ -240,7 +268,23 @@ window.__ModuleLoader__.load({
240
268
  "settings.invalidJson": "invalid JSON",
241
269
  "settings.what.form": "form",
242
270
  "settings.what.row": "row",
243
- "panel.activity.withLesson": " with a lesson · ",
271
+ // The counter strip and the activity line below are the two places a reader meets these
272
+ // numbers, and every word between them is a lookup: the unit words are `unit.*`, the rest are
273
+ // one-slot templates. Both halves are needed, because where the words go differs — English
274
+ // writes the plural noun then the verb (`{count} surfaced`), a language that takes a measure
275
+ // word before its noun writes one phrase (`{n} {unit}命中过课程`). Concatenating either half at
276
+ // the call site is what made the Chinese panel read `0 with a lesson · 0 lessons surfaced`,
277
+ // and splitting the phrase across two slots later made it read `1 篇 篇被课程复用`.
278
+ "panel.strip.hitTpl": "{count} hit · ",
279
+ "panel.strip.missTpl": "{count} found nothing · ",
280
+ "panel.strip.surfacedTpl": "{count} surfaced · ",
281
+ "panel.strip.helpfulTpl": "({n} helpful) · ",
282
+ "panel.strip.convertedTpl": "({n} became a lesson)",
283
+ "panel.activity.reusedTpl": "{count} reused · ",
284
+ "panel.activity.reportsTpl": "{count} · ",
285
+ "panel.activity.filedTpl": "{count}",
286
+ "unit.conversion": "conversion",
287
+ "unit.conversions": "conversions",
244
288
  "footer.session": "session: ",
245
289
  "footer.actionHint": "Copy the MisakaNet summary of the session that last used it",
246
290
  "settings.copyPatch": "Copy patch snippet",
@@ -250,17 +294,20 @@ window.__ModuleLoader__.load({
250
294
  var zh = {
251
295
  "guide.description": "本会话问过什么、拿回了什么、你报了什么",
252
296
  "panel.asked": "本会话问过什么",
253
- "panel.asked.none": "本会话还没有发起过 MisakaNet 检索。",
297
+ "panel.asked.none": "本会话还没有发起过 MisakaNet 检索 —— 入口就在输入框:先打 /misakanet,再写清楚出了什么问题。",
254
298
  "panel.reports": "你报过的问题",
255
299
  "panel.reports.none": "本会话没有报过任何问题。",
256
300
  "panel.trust": "这些课程的可信度",
257
- "panel.trust.none": "还没有命中过课程。",
301
+ "panel.trust.none": "还没有命中过课程 —— 入口就在输入框:先打 /misakanet,再写清楚出了什么问题。",
302
+ "panel.hint.open": "在输入框上方打开检索框",
303
+ "panel.hint.openLabel": "打开检索框",
304
+ "panel.hint.openTitle": "打开输入框自己的 MisakaNet 浮层,里面写着同样的用法。它不会替你在输入框里打字:视图组件只拿得到 props,草稿只有活的 session 作用域才能改,所以从这里没办法替你填入「/misakanet 」。",
258
305
  "panel.activity": "你的活动",
259
306
  "panel.voice": "语音",
260
307
  "panel.voice.on": "语音提示:开",
261
308
  "panel.voice.off": "语音提示:关",
262
309
  "overlay.searching": "检索中……",
263
- "overlay.empty": "在 /misakanet 后面写清楚出了什么问题,例如 /misakanet pip install 超时",
310
+ "overlay.empty": "在 /misakanet 后面写清楚出了什么问题,例如 /misakanet pip install 超时。",
264
311
  "overlay.none": "还没有课程覆盖这个问题。可以让 agent 报料(misakanet_submit_intake),或直接去 misakanet.org 提交 —— 下一个人就能在这里搜到它。",
265
312
  "overlay.failed": "检索失败:{error} —— 课程库在 misakanet.org;这次只发出了查询本身。",
266
313
  "overlay.footnote": "读取是匿名的。这个浮层只把查询发给 misakanet.org;除此之外,页面只会发出你的投票与报料。",
@@ -293,11 +340,15 @@ window.__ModuleLoader__.load({
293
340
  "unit.votes": "票",
294
341
  "unit.report": "条报料",
295
342
  "unit.reports": "条报料",
343
+ "unit.reportFiled": "条报料",
344
+ "unit.reportsFiled": "条报料",
345
+ "unit.retrieval": "次检索",
346
+ "unit.retrievals": "次检索",
296
347
  "panel.asked.searching": "检索中……",
297
348
  "panel.asked.noMatch": "没有命中任何课程",
298
349
  "panel.asked.top": "· 最佳:",
299
350
  "panel.asked.noQuery": "(没记下查询原文)",
300
- "panel.asked.nothingFound": "没有命中",
351
+ "panel.asked.nothingFound": "没查到结果",
301
352
  "panel.asked.voteHelpful": "有用",
302
353
  "panel.asked.voteNotHelpful": "没用",
303
354
  "panel.asked.voteNone": "未投票",
@@ -305,13 +356,26 @@ window.__ModuleLoader__.load({
305
356
  "panel.asked.voteHint": "与助手消息旁的裁决行是同一个动作,不用再往回滚。只发这篇课程的 id 和一个固定标签(`dsh-web`),不发别的。",
306
357
  "panel.asked.voteFailed": "连不上 misakanet.org —— 这次没有记下任何东西",
307
358
  "panel.asked.voteOne": "1 个人工确认 —— 复用事件现在出现了(E3:复用信号,不是 CI 验证)。",
308
- "panel.asked.voteTwo": "2 个人工确认 —— 已经是 agent 读到的 E4 了。",
309
- "panel.asked.voteMore": "个人工确认 —— 已经是 agent 读到的 E4 了。",
359
+ "panel.asked.voteTwo": "2 个人工确认 —— 只记为提示,不算证据:E4 那一档来自可观测的复用,投票给不了。",
360
+ "panel.asked.voteMore": "个人工确认 —— 只记为提示,不算证据:E4 那一档来自可观测的复用,投票给不了。",
310
361
  "panel.asked.noMatchTag": "无命中",
311
362
  "panel.asked.agentNext": "把这行交给 agent:",
312
363
  "panel.asked.agentNextCopy": "复制下一步",
313
364
  "panel.asked.agentNextText": "请用 MisakaNet 报料记下这个缺口,让下一个人能搜到:misakanet_submit_intake problem: ",
314
365
  "panel.asked.agentNextWhy": "这个页面不能替你报:报料的复查方式是原样再提交一次,而服务端的去重窗口是有限的 —— 页面触发的提交可能为同一个问题开出第二个 issue。",
366
+ "panel.hot": "全网返回最多的课程",
367
+ "panel.hot.loading": "正在读全网的检索排行……",
368
+ "panel.hot.none": "统计端点没有排出任何课程 —— 这一节宁可什么都不显示,也不编造一个 0。",
369
+ "panel.hot.rule": "每个数字是这篇课程被检索结果排到首位的次数 —— 来自网络自己的统计端点。它是一个观测到的计数:不是投票,不是认可,也不代表这篇课程真的帮上了忙。",
370
+ "panel.hot.limit": "检索次数最多的 {n} 篇课程 —— 端点返回的更多,这里只列到第 {n} 篇。",
371
+ "panel.hot.noCount": "端点没有给出次数",
372
+ "panel.coverage": "语料还答不上来的主题",
373
+ "panel.coverage.loading": "正在读网络的覆盖看板……",
374
+ "panel.coverage.none": "覆盖看板没有排出任何主题 —— 这一节宁可什么都不显示,也不猜。",
375
+ "panel.coverage.rule": "上面那一节数的是检索带回来了什么;这一节数的是没找到什么。每一行是一个主题,配最近 30 天里没有拿到答案的检索次数(读的是网络自己的覆盖看板),以及语料里匹配该主题的已发布课程数。",
376
+ "panel.coverage.lessonsTpl": "{n} {unit}在库 · ",
377
+ "panel.coverage.unsolvedTpl": "30 天内 {n} {unit}没有答案",
378
+ "panel.coverage.totals": "{n} {unit}已发布 · 下面是没有答案的检索最多的主题",
315
379
  "panel.counters": "本会话 · 计数只存在本浏览器 · 没有账号,除了你的投票与报料,不上传任何东西",
316
380
  "panel.report.title": "MisakaNet 报料",
317
381
  "panel.report.coveredBy": "已被 ",
@@ -332,12 +396,9 @@ window.__ModuleLoader__.load({
332
396
  "panel.trust.used": " · 已复用 ",
333
397
  "panel.trust.external": "外部复用证据:",
334
398
  "panel.trust.declared": "课程自述:",
335
- "panel.trust.e4signal": "实时信号:E4",
336
- "panel.trust.footnote": "等级来自课程自己存的证据;计数是别人的确认,不是你的:前者让复用事件出现,后者才是 agent 读到的 E4。",
399
+ "panel.trust.footnote": "等级来自课程自己存的证据;计数是别人的投票,不是你的 —— 只记为提示,不算证据。agent 读到的 E4 只来自可观测的复用,投票给不了。",
337
400
  "panel.activity.session": "本会话:",
338
401
  "panel.activity.browser": "本浏览器:",
339
- "panel.activity.reused": " 次复用 · ",
340
- "panel.activity.filed": " 条报料 · ",
341
402
  "panel.activity.conversion": "转化",
342
403
  "panel.activity.scope": "浏览器那一列能跨刷新保留;会话那一列由本次对话的转录重建,换一个会话就从零开始。",
343
404
  "panel.voice.cue": "提示音是服务端为上一次检索自己选的(",
@@ -345,7 +406,7 @@ window.__ModuleLoader__.load({
345
406
  "panel.voice.none": "还没有提示音:服务端会在本会话第一次检索时给出。",
346
407
  "panel.voice.play": "播放“",
347
408
  "panel.voice.hook": "本地钩子是另一回事:用 --voice 安装,用 MISAKANET_VOICE=0 静音,通过本机自己的播放器发声,还能弹桌面通知。页面读不到也改不了它,所以这个面板不报告它的状态。",
348
- "panel.footnote": "上面的数字都来自本次对话自己的转录,外加每篇课程一次公开 GET(/api/helpful)。可信计数是别人的确认,不是你自己的。重载窗口会丢掉会话行,浏览器计数会留下。这里的回执是 agent 自己调用返回的东西 —— 待处理报料会在 agent 再次提问时复查,而不是这个页面。",
409
+ "panel.footnote": "上面的数字都来自本次对话自己的转录,外加每篇课程一次公开 GET(/api/helpful)、一次对全网检索排行的读取(/api/analytics)和一次对覆盖看板的读取(/api/insights/lesson-coverage)。可信计数是别人的确认,不是你自己的。重载窗口会丢掉会话行,浏览器计数会留下。这里的回执是 agent 自己调用返回的东西 —— 待处理报料会在 agent 再次提问时复查,而不是这个页面。",
349
410
  "main.footnote": "左栏打开的就是这一页;会话级的计数在右栏的 MisakaNet 标签里。这里的数字是本浏览器贡献的部分。",
350
411
  "tool.rawResult": "原始结果",
351
412
  "tool.readFailed": "结果读不出来",
@@ -364,7 +425,16 @@ window.__ModuleLoader__.load({
364
425
  "settings.invalidJson": "JSON 不合法",
365
426
  "settings.what.form": "表单",
366
427
  "settings.what.row": "条目",
367
- "panel.activity.withLesson": " 篇命中课程 · ",
428
+ "panel.strip.hitTpl": "{n} {unit}命中过课程 · ",
429
+ "panel.strip.missTpl": "{n} {unit}没命中 · ",
430
+ "panel.strip.surfacedTpl": "{n} {unit}命中过课程 · ",
431
+ "panel.strip.helpfulTpl": "({n} 有用) · ",
432
+ "panel.strip.convertedTpl": "({n} 转化为课程)",
433
+ "panel.activity.reusedTpl": "{n} {unit}被课程复用 · ",
434
+ "panel.activity.reportsTpl": "{n} {unit} · ",
435
+ "panel.activity.filedTpl": "{n} {unit}",
436
+ "unit.conversion": "次转化",
437
+ "unit.conversions": "次转化",
368
438
  "footer.session": "会话:",
369
439
  "footer.actionHint": "复制最近一次使用 MisakaNet 的那个会话的摘要",
370
440
  "settings.copyPatch": "复制 patch 片段",
@@ -620,6 +690,38 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
620
690
  var OVERLAY_LIMIT = 5;
621
691
  var OVERLAY_TIMEOUT_MS = 8000;
622
692
 
693
+ /**
694
+ * How many rows section ⑥ shows of `/api/analytics`'s `top_lessons`.
695
+ *
696
+ * The owner asked for five and the endpoint returns ten, so the list is capped — and the cap is
697
+ * *printed* by the section (`panel.hot.limit`) rather than applied silently, because a reader who
698
+ * cannot see where a ranking was cut cannot tell it from a ranking that ended.
699
+ */
700
+ var HOT_LESSON_LIMIT = 5;
701
+
702
+ /**
703
+ * Section ⑦'s target, and the three reads it was chosen over.
704
+ *
705
+ * `/api/insights/lesson-coverage` is the other half of section ⑥'s question, not a second version
706
+ * of it: ⑥ borrows `/api/analytics` to rank *retrievals* — which lessons searches brought back —
707
+ * while this ranks *unmet demand*: `gaps` is ordered by `unsolved30d`, the searches in the last 30
708
+ * days that ended with no usable answer, and each row carries `lessonCount`, the published lessons
709
+ * that do match that topic. A reader who sees both should never think they are two orderings of the
710
+ * same number, which is what `panel.coverage.rule` says out loud.
711
+ *
712
+ * Of the four candidate reads measured live 2026-10-16 (all four answering
713
+ * `access-control-allow-origin: *`, so no server change is needed), this is the only one that both
714
+ * ranks something and says how much knowledge stands behind it. The others: `/api/insights/demand-board`
715
+ * is 146 bytes of family counters with no lesson counts in it; `/api/search-signals/stats` is 705 rows
716
+ * of `{solved, created_at}` — one per search, no lesson id — an unranked event log; `/api/lessons` is
717
+ * the corpus with no retrieval signal in any field, so any "hottest" order over it would be invented
718
+ * here. None of the four exposes a per-lesson retrieval count, which is why ⑥ and ⑦ split the
719
+ * question the way they do: ⑥ has the lessons and no topics, ⑦ has the topics and no individual
720
+ * lessons. The finest grain ⑦ can honestly speak at is the task family.
721
+ */
722
+ var COVERAGE_ENDPOINT = "https://misakanet.org/api/insights/lesson-coverage";
723
+ var COVERAGE_LIMIT = 5;
724
+
623
725
  /**
624
726
  * The label every vote from this browser half carries: what cast it, not who.
625
727
  *
@@ -959,11 +1061,21 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
959
1061
  *
960
1062
  * `many` is explicit rather than `singular + "s"` because English is not regular: the first
961
1063
  * version of this helper printed "2 searchs", which its own verification run showed and the
962
- * commit message then misquoted. Passing the plural at the call site makes the irregular one
963
- * impossible to get wrong silently.
1064
+ * commit message then misquoted.
1065
+ *
1066
+ * The fallback keeps `many` optional, but it may only ever *derive an English plural*. A unit
1067
+ * that is not an English word — every `unit.*` key holds a Chinese one under zh — does not
1068
+ * pluralize with a trailing "s", so appending one did not make the count wrong in a way a reader
1069
+ * could parse: it made `count(0, "票")` print `"票s"` and `count(0, "转化")` print `"转化s"`,
1070
+ * which is the screenshot this file was fixed from. A unit that is not `[A-Za-z]…` therefore
1071
+ * keeps its singular in the plural slot, and the guard is the *character class*, not a list of
1072
+ * nouns: a new unit in a language this helper has never seen cannot grow an English plural.
1073
+ * Where English itself is irregular the call site still passes `many`, because no default can
1074
+ * know "search" wants "searches".
964
1075
  */
965
1076
  function count(n, singular, many) {
966
- return n + " " + (n === 1 ? singular : many || singular + "s");
1077
+ var plural = many || (/^[A-Za-z][A-Za-z '\u2019-]*$/.test(singular) ? singular + "s" : singular);
1078
+ return n + " " + (n === 1 ? singular : plural);
967
1079
  }
968
1080
 
969
1081
  /** mm:ss for today, else the date — the event's own time, never the render time. */
@@ -1218,7 +1330,9 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1218
1330
  * counters — the review surface, as opposed to the two judging/visibility rows in the transcript.
1219
1331
  *
1220
1332
  * Every number here has a named source: this session's own log (the rows above), the browser's own
1221
- * counters, one public `GET /api/helpful` per lesson, and nothing else. The limits are printed in
1333
+ * counters, one public `GET /api/helpful` per lesson, one read of the network's retrieval ranking
1334
+ * (`GET /api/analytics`, section ⑥), one read of its coverage board
1335
+ * (`/api/insights/lesson-coverage`, section ⑦), and nothing else. The limits are printed in
1222
1336
  * the panel rather than hidden: a reload loses the log, and the local voice hook is not ours to
1223
1337
  * read.
1224
1338
  */
@@ -1237,6 +1351,19 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1237
1351
  var voteState = react.useState({});
1238
1352
  var panelVotes = voteState[0];
1239
1353
  var setPanelVotes = voteState[1];
1354
+ // The retrieval read's own state, and deliberately a third value: `undefined` is "not answered
1355
+ // yet", `null` is "asked and there is no ranking". Collapsing them would make the first paint
1356
+ // say the endpoint ranked nothing, which is a claim the page has not earned yet — and it would
1357
+ // leave no way to tell an empty ranking from one still in flight.
1358
+ var hotState = react.useState(undefined);
1359
+ var hot = hotState[0];
1360
+ var setHot = hotState[1];
1361
+ // Section ⑦'s own state, and the same third value for the same reason: `undefined` is "not
1362
+ // answered yet", `null` is "asked and there is no ranking". Collapsing them would make the first
1363
+ // paint say the board ranked nothing, which is a claim the page has not earned yet.
1364
+ var coverageState = react.useState(undefined);
1365
+ var coverage = coverageState[0];
1366
+ var setCoverage = coverageState[1];
1240
1367
 
1241
1368
  react.useEffect(function () {
1242
1369
  return subscribe(sessionId, function () {
@@ -1293,6 +1420,82 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1293
1420
  }
1294
1421
  }, [revisionState[0], lessons.length]);
1295
1422
 
1423
+ // ⑥ the network's most-retrieved lessons, read once per mount.
1424
+ //
1425
+ // The owner asked to see the corpus's hottest lessons without waiting for an agent to surface
1426
+ // one, and the ranking that needs already exists: `GET /api/analytics` — public, `no-store`, and
1427
+ // `access-control-allow-origin: *` (measured 2026-10-16) — carries `top_lessons`, one
1428
+ // `{lesson_id, count}` per lesson, ordered by how often that lesson came back as a search's top
1429
+ // hit.
1430
+ //
1431
+ // **Why this endpoint and not the coverage board** (`/api/insights/lesson-coverage`, the read
1432
+ // PR #3351 adds): the two rank different things and both are honest. The coverage board ranks
1433
+ // *topics* by unmet demand (`unsolved30d`, searches that ended with no usable answer) and says
1434
+ // how many lessons match each topic; it carries no per-lesson retrieval count at all, so a
1435
+ // "hottest lesson" list read from it would be invented here. This endpoint ranks retrievals and
1436
+ // says nothing about whether the lesson helped — a price the section states out loud rather than
1437
+ // dressing the count as a verdict.
1438
+ //
1439
+ // **What is deliberately not rendered: `top_searches`**, the same payload's unfiltered query
1440
+ // list. On 2026-10-16 it led with `pip install timeout` and `Error response from daemon: exit
1441
+ // code 137`, then with `npm run build`, `init` and `start` — generic strings a build tool or a
1442
+ // shell prompt emits, not failure topics. It also carries no lesson link, so it cannot be paired
1443
+ // with the answer that satisfied it. Rendering it as "what people ask" would put noise on the
1444
+ // panel at the same weight as knowledge, so this section shows the ranking the payload really
1445
+ // provides and leaves the query list alone. Nothing here averages it in.
1446
+ //
1447
+ // The read is scheduled, never awaited on the render path: the panel's first paint must not wait
1448
+ // on misakanet.org. `askedHot` is the same once-only guard the per-lesson `GET /api/helpful`
1449
+ // uses, because the effect can re-run and a public endpoint should not be asked twice for one
1450
+ // answer.
1451
+ var askedHot = react.useRef(false);
1452
+ react.useEffect(function () {
1453
+ if (askedHot.current) return;
1454
+ askedHot.current = true;
1455
+ fetch(API + "/api/analytics")
1456
+ .then(function (response) { return response.ok ? response.json() : null; })
1457
+ .then(function (body) {
1458
+ var ranked = body && Array.isArray(body.top_lessons) ? body.top_lessons : null;
1459
+ // An absent or empty `top_lessons` is "no ranking", never a zero: a manufactured `0`
1460
+ // would name a lesson the endpoint did not rank.
1461
+ setHot(ranked && ranked.length ? ranked : null);
1462
+ })
1463
+ .catch(function () { setHot(null); });
1464
+ }, []);
1465
+
1466
+ // ⑦ what the corpus cannot answer yet, read once per mount.
1467
+ //
1468
+ // The complement of ⑥, and deliberately not a re-sort of it. ⑥ ranks *lessons by retrievals*
1469
+ // (`/api/analytics`'s `top_lessons`); this ranks *topics by unanswered searches*
1470
+ // (`/api/insights/lesson-coverage`'s `gaps`, `unsolved30d` descending) and prints how many
1471
+ // published lessons match each topic. Two different measurements of two different things: what
1472
+ // the corpus returns most, and where it has nothing to return. `panel.coverage.rule` states the
1473
+ // difference where the reader meets it, because a panel with two rankings that never says how
1474
+ // they differ invites the reader to assume one is a re-sort of the other.
1475
+ //
1476
+ // Why this endpoint rather than `/api/analytics`: the retrieval ranking carries no topic and no
1477
+ // gap count, and none of the four reads measured on 2026-10-16 carries a per-lesson retrieval
1478
+ // count *and* a topic. `gaps` is the only public ranking of unmet demand, and `lessonCount` is
1479
+ // the only public statement of how much knowledge stands behind each topic.
1480
+ //
1481
+ // Scheduled, never awaited on the render path — the first paint must not wait on misakanet.org —
1482
+ // and guarded by a ref for the same reason as ⑥: the effect can re-run and a public endpoint
1483
+ // should not be asked twice for one answer.
1484
+ var askedCoverage = react.useRef(false);
1485
+ react.useEffect(function () {
1486
+ if (askedCoverage.current) return;
1487
+ askedCoverage.current = true;
1488
+ fetch(COVERAGE_ENDPOINT)
1489
+ .then(function (response) { return response.ok ? response.json() : null; })
1490
+ .then(function (body) {
1491
+ var ranked = body && body.success && Array.isArray(body.gaps) ? body.gaps : null;
1492
+ // An absent or empty `gaps` is "no ranking", never a list of zeroes: the totals line
1493
+ // would otherwise describe a corpus standing behind a ranking that does not exist.
1494
+ setCoverage(ranked && ranked.length ? body : null);
1495
+ })
1496
+ .catch(function () { setCoverage(null); });
1497
+ }, []);
1498
+
1296
1499
  var rows = [];
1297
1500
  // The same mark as the plugin-list icon and the sidebar chip: the conversation ring has no icon
1298
1501
  // channel, so the panel body is the one place the tab can show what it belongs to.
@@ -1301,11 +1504,17 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1301
1504
  rows.push(h("div", { key: "scope", style: DIM },
1302
1505
  T("panel.counters")));
1303
1506
 
1507
+ // The strip and the activity line below are the two places a reader meets these counters, and
1508
+ // every word between the numbers is a lookup. Hard-coding it at the call site is what made the
1509
+ // Chinese panel read `0 with a lesson · 0 lessons surfaced · 0 became a lesson` — the numbers
1510
+ // were right and the sentence was English.
1304
1511
  rows.push(h("div", { key: "strip", style: Object.assign({}, BOX, { marginTop: "8px" }) },
1305
- count(searches.length, T("unit.search"), T("unit.searches")) + " · " + withLesson + " with a lesson · " +
1306
- noMatch + " " + T("panel.asked.nothingFound") + " · " + count(lessons.length, "lesson") + " surfaced · " +
1307
- count(votes.length, T("unit.vote")) + " (" + helpful + " helpful) · " +
1308
- count(intakes.length, "report") + " (" + converted + " became a lesson)"));
1512
+ count(searches.length, T("unit.search"), T("unit.searches")) + " · " +
1513
+ T("panel.strip.hitTpl", { count: count(withLesson, T("unit.lesson"), T("unit.lessons")), n: withLesson, unit: T("unit.lessons") }) +
1514
+ T("panel.strip.missTpl", { count: count(noMatch, T("unit.lesson"), T("unit.lessons")), n: noMatch, unit: T("unit.lessons") }) +
1515
+ T("panel.strip.surfacedTpl", { count: count(lessons.length, T("unit.lesson"), T("unit.lessons")), n: lessons.length, unit: T("unit.lessons") }) +
1516
+ count(votes.length, T("unit.vote"), T("unit.votes")) + " " + T("panel.strip.helpfulTpl", { count: count(helpful, T("unit.vote"), T("unit.votes")), n: helpful, unit: T("unit.votes") }) +
1517
+ count(intakes.length, T("unit.report"), T("unit.reports")) + " " + T("panel.strip.convertedTpl", { count: count(converted, T("unit.report"), T("unit.reports")), n: converted, unit: T("unit.reports") })));
1309
1518
 
1310
1519
  // ① what this session asked
1311
1520
  //
@@ -1352,9 +1561,47 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1352
1561
  );
1353
1562
  }
1354
1563
 
1564
+ /**
1565
+ * The entry point, which the panel is the first place a person looks for and the last place
1566
+ * it used to mention.
1567
+ *
1568
+ * The real entry is the composer's `/misakanet` source, and the empty states below said only
1569
+ * what was missing (`panel.asked.none`, `panel.trust.none`). What was checked before settling
1570
+ * for a hint plus this one button:
1571
+ *
1572
+ * * **Prefilling the composer is out of reach.** The draft lives behind
1573
+ * `ctx.conversation.input.for(actx).setDraft(text)` — a *session-scoped* service the panel
1574
+ * cannot hold: a view occupant is handed props, and the panel's `apply(ctx)` is the client
1575
+ * root. Reaching it would mean injecting a service this plugin does not declare, which on a
1576
+ * line without it is the dead-button case this file already refuses (see the deferred
1577
+ * `sidebarRightTabs` inject). So no prefilled `/misakanet ` and no text that claims one.
1578
+ * * **Opening the overlay is in reach**, because it is this module's own state: the card
1579
+ * registered on `conversation.input.overlay` renders from `overlayOf(sessionId)`, and the
1580
+ * panel receives that same session id. `runOverlaySearch(sessionId, "")` is the module's one
1581
+ * opener; it starts no request for an empty query and sends nothing — it just puts
1582
+ * `overlay.empty` in front of the person, where the instruction now is.
1583
+ *
1584
+ * The button is offered only with a session id (the `main` seat gets none): without one there is
1585
+ * no overlay to open, and a button that cannot do what its label says is worse than the text.
1586
+ */
1587
+ function openOverlayFromPanel() {
1588
+ if (!sessionId) return;
1589
+ runOverlaySearch(sessionId, "");
1590
+ }
1591
+
1592
+ /** The section's own empty-state sentence, plus the overlay opener when a session can take it. */
1593
+ function entryPointHint(key, message) {
1594
+ return h("div", { key: key, style: DIM },
1595
+ T(message) + " ",
1596
+ sessionId ? h("button", {
1597
+ type: "button", style: BUTTON, title: T("panel.hint.openTitle"),
1598
+ onClick: openOverlayFromPanel,
1599
+ }, T("panel.hint.openLabel")) : null);
1600
+ }
1601
+
1355
1602
  rows.push(h("div", { key: "s1", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.asked")));
1356
1603
  if (!searches.length) {
1357
- rows.push(h("div", { key: "s1e", style: DIM }, T("panel.asked.none")));
1604
+ rows.push(entryPointHint("s1e", "panel.asked.none"));
1358
1605
  } else {
1359
1606
  var searchRows = searches.slice().reverse().slice(0, 20).map(function (entry) {
1360
1607
  var key = "q" + entry.callId + entry.at;
@@ -1458,15 +1705,18 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1458
1705
  // richer receipt (per-event types, `count`/`queries`/`sources`, its own `evidence`) but it is an
1459
1706
  // MCP tool, reachable only through the MCP endpoint with a JSON-RPC `tools/call` — and this
1460
1707
  // browser half is forbidden from making one (acceptance D4: the same endpoint files issues). The
1461
- // count therefore carries the helpful-vote signal, and the regression citations and cross-node
1462
- // confirmations the tool also aggregates are **not** rendered. Nothing here is guessed: no field
1708
+ // count therefore carries the helpful-vote signal, and the observed distinct-client retrieval,
1709
+ // the regression citations and the declared-provenance hint the tool also aggregates are **not**
1710
+ // rendered. Nothing here is guessed: no field
1463
1711
  // this endpoint does not return is displayed.
1464
1712
  //
1465
- // The count itself is the same threshold `misakanet_me_events` reports: one helpful vote is a
1466
- // reuse signal (E3), two or more is E4 — which is also why the rule below is stated once.
1713
+ // The count is the helpful-vote signal only, and since 2026-10-16 a vote is capped below E4 at
1714
+ // every count — no row here can claim the rung, which is also why the rule below is stated once.
1715
+ // (`E4` itself is observed elsewhere: `misakanet_me_events` counts distinct clients from
1716
+ // `search_signals`, which this browser half likewise cannot read.)
1467
1717
  rows.push(h("div", { key: "s3", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.trust")));
1468
1718
  if (!lessons.length) {
1469
- rows.push(h("div", { key: "s3e", style: DIM }, T("panel.trust.none")));
1719
+ rows.push(entryPointHint("s3e", "panel.trust.none"));
1470
1720
  } else {
1471
1721
  // The rule once, not per row: it is the same sentence for every lesson, and repeating it
1472
1722
  // four times buries the numbers it is there to explain.
@@ -1476,10 +1726,10 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1476
1726
  var confirmations = trust[lesson.lessonId];
1477
1727
  var counted = confirmations === undefined ? T("panel.trust.checking")
1478
1728
  : (confirmations === null ? T("panel.trust.unavailable")
1479
- : count(confirmations, T("panel.trust.human")) + (confirmations >= 2 ? " → E4" : ""));
1729
+ : count(confirmations, T("panel.trust.human")));
1480
1730
  var declared = lesson.evidence || T("panel.trust.noLevel");
1481
1731
  var external = h("span", { style: DIM },
1482
- T("panel.trust.external") + counted + (confirmations >= 2 ? " · " + T("panel.trust.e4signal") : ""));
1732
+ T("panel.trust.external") + counted);
1483
1733
  return h("div", { key: "l" + lesson.lessonId, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1484
1734
  h("div", null,
1485
1735
  h("span", {}, lesson.title || lesson.lessonId),
@@ -1492,12 +1742,16 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1492
1742
  // ④ your activity, two scopes
1493
1743
  rows.push(h("div", { key: "s4", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.activity")));
1494
1744
  rows.push(h("div", { key: "s4r", style: BOX },
1495
- h("div", {}, T("panel.activity.session") + count(searches.length, "search", "searches") + " · " + count(lessons.length, "lesson") +
1496
- T("panel.activity.reused") + count(votes.length, "vote") + " · " + count(intakes.length, "report") + T("panel.activity.filed") +
1497
- count(converted, T("panel.activity.conversion"))),
1745
+ h("div", {}, T("panel.activity.session") + count(searches.length, T("unit.search"), T("unit.searches")) + " · " +
1746
+ T("panel.activity.reusedTpl", { count: count(lessons.length, T("unit.lesson"), T("unit.lessons")), n: lessons.length, unit: T("unit.lessons") }) +
1747
+ count(votes.length, T("unit.vote"), T("unit.votes")) + " · " +
1748
+ T("panel.activity.reportsTpl", { count: count(intakes.length, T("unit.reportFiled"), T("unit.reportsFiled")), n: intakes.length, unit: T("unit.reportsFiled") }) +
1749
+ T("panel.activity.filedTpl", { count: count(converted, T("unit.conversion"), T("unit.conversions")), n: converted, unit: T("unit.conversions") })),
1498
1750
  h("div", {}, T("panel.activity.browser") + count(stats.searches || 0, T("unit.search"), T("unit.searches")) + " · " +
1499
- count(stats.lessonsReused || 0, "lesson") + " reused · " + count(stats.votes || 0, "vote") + " · " +
1500
- count(stats.reportsFiled || 0, "report") + " filed · " + count(stats.reportsConverted || 0, "conversion")),
1751
+ T("panel.activity.reusedTpl", { count: count(stats.lessonsReused || 0, T("unit.lesson"), T("unit.lessons")), n: stats.lessonsReused || 0, unit: T("unit.lessons") }) +
1752
+ count(stats.votes || 0, T("unit.vote"), T("unit.votes")) + " · " +
1753
+ T("panel.activity.reportsTpl", { count: count(stats.reportsFiled || 0, T("unit.reportFiled"), T("unit.reportsFiled")), n: stats.reportsFiled || 0, unit: T("unit.reportsFiled") }) +
1754
+ T("panel.activity.filedTpl", { count: count(stats.reportsConverted || 0, T("unit.conversion"), T("unit.conversions")), n: stats.reportsConverted || 0, unit: T("unit.conversions") })),
1501
1755
  h("div", { style: DIM }, T("panel.activity.scope"))));
1502
1756
 
1503
1757
  // ⑤ voice
@@ -1522,6 +1776,76 @@ var DEFAULT_MCP_URL = "https://misakanet.org/mcp";
1522
1776
  : T("panel.voice.none")),
1523
1777
  h("div", { style: DIM }, T("panel.voice.hook"))));
1524
1778
 
1779
+ // ⑥ the network's most-retrieved lessons
1780
+ //
1781
+ // Read-only, and about the whole network rather than this session: no lesson here had to be
1782
+ // surfaced by the agent first. There is no vote affordance in this section and none of its copy
1783
+ // says the count is approval — the number is an observed retrieval, and the rule states what it
1784
+ // does and does not mean before the first row.
1785
+ rows.push(h("div", { key: "s6", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.hot")));
1786
+ if (hot === undefined) {
1787
+ rows.push(h("div", { key: "s6w", style: DIM }, T("panel.hot.loading")));
1788
+ } else if (hot === null) {
1789
+ // The endpoint ranked nothing (or did not answer). Saying so beats printing five rows of
1790
+ // `0 retrievals`, which would name lessons the payload never ranked.
1791
+ rows.push(h("div", { key: "s6e", style: DIM }, T("panel.hot.none")));
1792
+ } else {
1793
+ rows.push(h("div", { key: "s6rule", style: DIM }, T("panel.hot.rule")));
1794
+ rows.push(h("div", { key: "s6l", style: DIM }, T("panel.hot.limit", { n: HOT_LESSON_LIMIT })));
1795
+ rows.push(h("div", { key: "s6r" }, hot.slice(0, HOT_LESSON_LIMIT).map(function (entry) {
1796
+ var path = String(entry && entry.lesson_id ? entry.lesson_id : "");
1797
+ var times = entry && typeof entry.count === "number" ? entry.count : null;
1798
+ return h("div", { key: "h" + path, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1799
+ h("span", { style: { fontFamily: "monospace" } }, path),
1800
+ h("span", { style: DIM }, " · " + (times === null
1801
+ ? T("panel.hot.noCount")
1802
+ : count(times, T("unit.retrieval"), T("unit.retrievals")))));
1803
+ })));
1804
+ }
1805
+
1806
+ // ⑦ the topics the corpus cannot answer yet
1807
+ //
1808
+ // Why ⑦ and not ⑥: `main` already numbers the retrieval ranking ⑥ (and owns `key: "s6"` plus the
1809
+ // `panel.hot` keys), so this section takes the next number and the `s7` key rather than sharing
1810
+ // one — two sections with the same React key is a duplicate-key defect, and two sections that
1811
+ // both claim to be ⑥ make the rule lines unreadable. Every key here is `s7*`, and the dictionary
1812
+ // family is `panel.coverage*`, so neither half of that identity is borrowed.
1813
+ //
1814
+ // Read-only, and about the whole network rather than this session: no topic here had to be
1815
+ // searched first. There is no vote affordance in this section and none of its copy says the order
1816
+ // is approval — `unsolved30d` counts unanswered searches, and the rule says what it counts before
1817
+ // the first row and how it differs from the retrieval ranking above.
1818
+ rows.push(h("div", { key: "s7", style: { marginTop: "12px", fontWeight: 600 } }, T("panel.coverage")));
1819
+ if (coverage === undefined) {
1820
+ rows.push(h("div", { key: "s7w", style: DIM }, T("panel.coverage.loading")));
1821
+ } else if (coverage === null) {
1822
+ // The board ranked nothing (or did not answer). Saying so beats printing rows of `0`, which
1823
+ // would name topics the payload never ranked.
1824
+ rows.push(h("div", { key: "s7e", style: DIM }, T("panel.coverage.none")));
1825
+ } else {
1826
+ rows.push(h("div", { key: "s7rule", style: DIM }, T("panel.coverage.rule")));
1827
+ var coverageMetrics = coverage.metrics || {};
1828
+ if (typeof coverageMetrics.publishedLessons === "number") {
1829
+ rows.push(h("div", { key: "s7t", style: DIM },
1830
+ T("panel.coverage.totals", {
1831
+ count: count(coverageMetrics.publishedLessons, T("unit.lesson"), T("unit.lessons")),
1832
+ n: coverageMetrics.publishedLessons, unit: T("unit.lessons"),
1833
+ })));
1834
+ }
1835
+ rows.push(h("div", { key: "s7r" }, coverage.gaps.slice(0, COVERAGE_LIMIT).map(function (gap) {
1836
+ return h("div", { key: "f" + gap.taskFamily, style: { borderTop: "1px solid " + MUTED, padding: "4px 0" } },
1837
+ h("span", {}, gap.taskFamily),
1838
+ h("span", { style: DIM }, " " + T("panel.coverage.lessonsTpl", {
1839
+ count: count(gap.lessonCount, T("unit.lesson"), T("unit.lessons")),
1840
+ n: gap.lessonCount, unit: T("unit.lessons"),
1841
+ })),
1842
+ h("span", { style: DIM }, T("panel.coverage.unsolvedTpl", {
1843
+ count: count(gap.unsolved30d, T("unit.search"), T("unit.searches")),
1844
+ n: gap.unsolved30d, unit: T("unit.searches"),
1845
+ })));
1846
+ })));
1847
+ }
1848
+
1525
1849
  rows.push(h("div", { key: "foot", style: Object.assign({}, DIM, { marginTop: "12px" }) },
1526
1850
  T("panel.footnote")));
1527
1851