dsh-plugin-lookatstudy 0.22.1 → 0.22.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.
package/lib/client.js CHANGED
@@ -391,6 +391,14 @@ window.__ModuleLoader__.load({
391
391
  .lks-ui .lks14-emptycard-hint{font-size:.825rem;color:var(--ink-muted);line-height:1.6}
392
392
  /* B5: notebook tab capsule + 960px reading column */
393
393
  .lks-ui .lks14-notebody{margin:0 auto;max-width:960px;width:100%;padding:12px 20px 24px;box-sizing:border-box}
394
+ /* #10-followup: the reading column is height:auto (the note col scrolls), so
395
+ .lks14-board's height:100% resolved against a content-sized parent and the
396
+ stage collapsed to a ~90px sliver — the never-upscale contain fit then
397
+ scaled tall artifacts to tens of px. On the board tab the body fills the
398
+ col instead (a flex column); the notes/cmap tabs keep the scrolling
399
+ reading column. */
400
+ .lks-ui .lks14-notebody.lks14-notebody-fill{display:flex;flex-direction:column;flex:1 1 0;min-height:0;max-width:none;padding-bottom:8px}
401
+ .lks-ui .lks14-notebody-fill .lks14-board{flex:1 1 0}
394
402
  .lks-ui .lks14-viewtabs{background:var(--surface-2);border-radius:10px;padding:3px;align-self:flex-start}
395
403
  .lks-ui .lks14-readbar{position:sticky;top:0;z-index:20;background:var(--surface-2);border-radius:10px}
396
404
 
@@ -484,8 +492,12 @@ window.__ModuleLoader__.load({
484
492
  .lks-ui .lks14-board-artifact{padding:20px;width:fit-content;max-width:1200px}
485
493
  /* issue #10: the board stage handles oversize via zoom — the table lays out
486
494
  normally inside a bounded card (display:block + overflow was made for the
487
- narrow chat column; on the canvas it broke cells into overlap) */
488
- .lks-ui .lks14-board-artifact .lks-acard-table{display:table;width:100%;max-width:100%;overflow:visible}
495
+ narrow chat column; on the canvas it broke cells into overlap).
496
+ #10-followup: width:100% made the table's width depend on the fit-content
497
+ wrapper WHILE the wrapper's width depended on the table — a circular
498
+ dependency that collapsed to near-zero in the wild; width:auto lets both
499
+ resolve from content (the wrapper hugs the table's max-content). */
500
+ .lks-ui .lks14-board-artifact .lks-acard-table{display:table;width:auto;max-width:100%;overflow:visible}
489
501
  .lks-ui .lks14-board-artifact .lks-acard{min-width:560px;background:var(--surface-0);border:1px solid var(--border-faint);border-radius:14px}
490
502
  .lks-ui .lks14-cmapwrap{position:relative}
491
503
  .lks-ui .lks14-cmap-expand{position:absolute;top:10px;right:10px;z-index:5}
@@ -1689,8 +1701,16 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1689
1701
  */
1690
1702
  /** Poll cadence for the shared store; one cycle serves every mounted seat. */
1691
1703
  const POLL_MS = 3e3;
1704
+ /** Sentinel for 304 responses (the state poll's ETag path): keep the snapshot. */
1705
+ const NOT_MODIFIED = Symbol("not-modified");
1692
1706
  async function fetchJson(url, init) {
1693
- const res = await fetch(url, init);
1707
+ const headers = new Headers(init?.headers);
1708
+ headers.set("x-lks-request", "1");
1709
+ const res = await fetch(url, {
1710
+ ...init,
1711
+ headers
1712
+ });
1713
+ if (res.status === 304) return NOT_MODIFIED;
1694
1714
  const body = await res.json().catch(() => null);
1695
1715
  if (!res.ok) {
1696
1716
  const error = body !== null && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : `HTTP ${res.status}`;
@@ -1728,8 +1748,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1728
1748
  this.inflight = true;
1729
1749
  try {
1730
1750
  const next = await fetchJson("/lookatstudy/api/state");
1731
- this.snapshot = next;
1732
- this.emit();
1751
+ if (next !== NOT_MODIFIED) {
1752
+ this.snapshot = next;
1753
+ this.emit();
1754
+ }
1733
1755
  } catch {} finally {
1734
1756
  this.inflight = false;
1735
1757
  }
@@ -1968,7 +1990,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1968
1990
  const bin = atob(raw);
1969
1991
  const bytes = new Uint8Array(bin.length);
1970
1992
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1971
- return bytes.buffer;
1993
+ return bytes;
1972
1994
  }
1973
1995
  };
1974
1996
  /** The voices the dashboard route accepts (same allowlist server-side). */
@@ -2028,6 +2050,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2028
2050
  uploadAttachment: studyStore.uploadAttachment.bind(studyStore),
2029
2051
  setFocus: studyStore.setFocus.bind(studyStore),
2030
2052
  searchLessons: studyStore.searchLessons.bind(studyStore),
2053
+ tts: studyStore.tts.bind(studyStore),
2031
2054
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2032
2055
  deleteNote: studyStore.deleteNote.bind(studyStore),
2033
2056
  editNote: studyStore.editNote.bind(studyStore),
@@ -2086,7 +2109,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2086
2109
  "quiz.right": "答对了",
2087
2110
  "quiz.wrong": "答错了",
2088
2111
  "quiz.youChose": "你选了",
2089
- "quiz.answer": "正确答案",
2090
2112
  "quiz.hook": "练习卡完成:{correct}/{total} 正确。请简短点评,针对错题讲讲思路。",
2091
2113
  "quiz.action.explain-wrong": "讲讲错题",
2092
2114
  "quiz.action.retry": "再来一组",
@@ -2122,8 +2144,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2122
2144
  "review.nudge.go": "去复习",
2123
2145
  "rail.search.jump": "打开这一课",
2124
2146
  "proposal.banner": "《{lesson}》可以提前毕业了",
2125
- "proposal.accept": "接受",
2126
- "proposal.decline": "再练练",
2127
2147
  "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
2128
2148
  "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
2129
2149
  "tutor.thinking": "导师思考中…",
@@ -2404,7 +2424,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2404
2424
  "exam.leave.message": "考试进行中——现在离开,未答的题将按错计分。",
2405
2425
  "exam.leave.confirm": "离开并结算",
2406
2426
  "exam.leave.cancel": "继续答题",
2407
- "prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_view 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
2427
+ "prompt.exam.bank": "请为「{section}」的章节测验出题:先用 study_lesson 读一遍本节课时,再按各课时的知识点用 study_exam_bank_apply 一次性提交整份题库(单选 5-15 题,kcTitle 必须用本节的概念标题,每题附一句话解析)。题库就绪后告诉我可以开考。",
2408
2428
  "prompt.review": "开始今天的复习,从最到期的课时开始",
2409
2429
  "prompt.proposal.accept": "接受提案 {id} —— 确认标记这课为已掌握",
2410
2430
  "prompt.proposal.decline": "拒绝提案 {id} —— 我想再练练"
@@ -2449,7 +2469,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2449
2469
  "quiz.right": "Correct",
2450
2470
  "quiz.wrong": "Wrong",
2451
2471
  "quiz.youChose": "You chose",
2452
- "quiz.answer": "Answer",
2453
2472
  "quiz.hook": "Practice card finished: {correct}/{total} correct. Please comment briefly and walk through the wrong ones.",
2454
2473
  "quiz.action.explain-wrong": "Explain my wrong answers",
2455
2474
  "quiz.action.retry": "Another set",
@@ -2485,8 +2504,6 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2485
2504
  "review.nudge.go": "Review now",
2486
2505
  "rail.search.jump": "Open this lesson",
2487
2506
  "proposal.banner": "{lesson} is ready to graduate early",
2488
- "proposal.accept": "Accept",
2489
- "proposal.decline": "Keep practicing",
2490
2507
  "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
2491
2508
  "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
2492
2509
  "tutor.thinking": "The tutor is thinking…",
@@ -2767,7 +2784,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2767
2784
  "exam.leave.message": "The exam is in progress — leaving now counts unanswered questions as wrong.",
2768
2785
  "exam.leave.confirm": "Leave & settle",
2769
2786
  "exam.leave.cancel": "Keep answering",
2770
- "prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_view, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
2787
+ "prompt.exam.bank": "Author the section exam bank for \"{section}\": read this section's lessons with study_lesson, then submit the full multiple-choice set in one study_exam_bank_apply call (5-15 single-choice questions, kcTitle must be one of the section's concept titles, one-line explanation each). Tell me when the bank is ready.",
2771
2788
  "prompt.review": "Start today's review, from the most overdue lesson first",
2772
2789
  "prompt.proposal.accept": "Accept proposal {id} — confirm marking this lesson as mastered",
2773
2790
  "prompt.proposal.decline": "Reject proposal {id} — I want to practice more"
@@ -3076,6 +3093,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3076
3093
  //#endregion
3077
3094
  //#region src/client/enhance.ts
3078
3095
  var enhance_exports = /* @__PURE__ */ __exportAll({
3096
+ KATEX_SRI: () => KATEX_SRI,
3097
+ MERMAID_SECURITY_LEVEL: () => MERMAID_SECURITY_LEVEL,
3079
3098
  attachCodeHeader: () => attachCodeHeader,
3080
3099
  enhanceCode: () => enhanceCode,
3081
3100
  enhanceDiagrams: () => enhanceDiagrams,
@@ -3127,10 +3146,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3127
3146
  mermaid: "https://esm.sh/mermaid@11.4.1",
3128
3147
  mermaidElk: "https://esm.sh/@mermaid-js/layout-elk@0.1.7"
3129
3148
  };
3130
- function loadScript(src) {
3149
+ function loadScript(src, integrity) {
3131
3150
  return new Promise((resolve, reject) => {
3132
3151
  const el = document.createElement("script");
3133
3152
  el.src = src;
3153
+ if (integrity !== void 0) {
3154
+ el.integrity = integrity;
3155
+ el.crossOrigin = "anonymous";
3156
+ }
3134
3157
  el.onload = () => {
3135
3158
  resolve();
3136
3159
  };
@@ -3140,6 +3163,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3140
3163
  document.head.append(el);
3141
3164
  });
3142
3165
  }
3166
+ /** Audit D32: SRI pin for the KaTeX CDN script (0.16.22, sha384 over the exact bytes). */
3167
+ const KATEX_SRI = "sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6";
3143
3168
  let katexPromise = null;
3144
3169
  function defaultLoadKatex() {
3145
3170
  katexPromise ??= (async () => {
@@ -3150,7 +3175,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3150
3175
  link.setAttribute("data-lks-katex", "1");
3151
3176
  document.head.append(link);
3152
3177
  }
3153
- if (window.katex === void 0) await loadScript(CDN.katexJs);
3178
+ if (window.katex === void 0) await loadScript(CDN.katexJs, KATEX_SRI);
3154
3179
  const katex = window.katex;
3155
3180
  if (katex === void 0) throw new Error("katex missing after load");
3156
3181
  return katex;
@@ -3239,6 +3264,13 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3239
3264
  noteTextColor: read("--ink-rgb", "rgb(245 245 250)")
3240
3265
  };
3241
3266
  }
3267
+ /**
3268
+ * Audit A1 (2026-09-14): diagram sources arrive from imported (untrusted)
3269
+ * content — mermaid must run under 'strict'. 'loose' skips DOMPurify label
3270
+ * sanitization and enables click callbacks, which turns a hostile repo
3271
+ * README into stored XSS in the host page. Exported so the gate is testable.
3272
+ */
3273
+ const MERMAID_SECURITY_LEVEL = "strict";
3242
3274
  function defaultLoadMermaid() {
3243
3275
  mermaidPromise ??= (async () => {
3244
3276
  const mermaid = (await import(
@@ -3255,7 +3287,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3255
3287
  mermaid.initialize({
3256
3288
  startOnLoad: false,
3257
3289
  theme: "base",
3258
- securityLevel: "loose",
3290
+ securityLevel: MERMAID_SECURITY_LEVEL,
3259
3291
  themeVariables: mermaidThemeVariables()
3260
3292
  });
3261
3293
  return mermaid;
@@ -3267,19 +3299,14 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3267
3299
  const display = /\$\$([^$]+)\$\$/.exec(text);
3268
3300
  const inline = /\$([^$\n]+)\$/.exec(text);
3269
3301
  if (display === null && inline === null) return null;
3270
- const m = display !== null && (inline === null || display.index <= inline.index) ? {
3271
- ...display,
3272
- display: true
3273
- } : inline === null ? null : {
3274
- ...inline,
3275
- display: false
3276
- };
3302
+ const useDisplay = display !== null && (inline === null || display.index <= inline.index);
3303
+ const m = useDisplay ? display : inline;
3277
3304
  if (m === null) return null;
3278
3305
  return {
3279
3306
  before: text.slice(0, m.index),
3280
3307
  tex: m[1],
3281
3308
  after: text.slice(m.index + m[0].length),
3282
- display: m.display
3309
+ display: useDisplay
3283
3310
  };
3284
3311
  }
3285
3312
  /** KaTeX every $..$ / $$..$$ in the container; degrades to plain text on any failure. */
@@ -3294,7 +3321,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3294
3321
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
3295
3322
  const targets = [];
3296
3323
  let node;
3297
- while ((node = walker.nextNode()) !== null) if (findMathSpan(node.data) !== null && node.parentElement?.tagName !== "CODE") targets.push(node);
3324
+ while ((node = walker.nextNode()) !== null) {
3325
+ const parent = node.parentElement;
3326
+ if (findMathSpan(node.data) !== null && parent?.tagName !== "CODE" && parent?.closest(".lks-shiki, .lks-mermaid") === null) targets.push(node);
3327
+ }
3298
3328
  for (const t of targets) {
3299
3329
  let guard = 0;
3300
3330
  while (guard++ < 200) {
@@ -3474,7 +3504,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3474
3504
  * no zoom; this is the one place our port must normalize).
3475
3505
  */
3476
3506
  function panelZoomOf(el) {
3477
- const z = el?.closest(".lks14")?.style.zoom;
3507
+ const z = (el?.closest(".lks14"))?.style.zoom;
3478
3508
  const n = z === void 0 || z === "" ? NaN : Number(z);
3479
3509
  return Number.isFinite(n) && n > 0 ? n : 1;
3480
3510
  }
@@ -3631,25 +3661,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3631
3661
  for (const line of text.split("\n")) {
3632
3662
  const plain = /^([A-D])[.、:)]\s*(.+)$/.exec(line.trim());
3633
3663
  const table = /^\|\s*\*{0,2}([A-D])\*{0,2}\s*\|\s*(.+?)\s*\|$/.exec(line.trim());
3634
- const match = plain ?? (table !== null ? [
3635
- table[0],
3636
- table[1],
3637
- table[2].replaceAll("**", "").replaceAll("`", "")
3638
- ] : null);
3664
+ const match = plain !== null ? [plain[1], plain[2]] : table !== null ? [table[1], table[2].replaceAll("**", "").replaceAll("`", "")] : null;
3639
3665
  if (match === null) {
3640
3666
  flush();
3641
3667
  continue;
3642
3668
  }
3643
3669
  const nextLetter = run.length === 0 ? "A" : String.fromCharCode(run[run.length - 1].letter.charCodeAt(0) + 1);
3644
- if (match[1] === nextLetter) run.push({
3645
- letter: match[1],
3646
- text: match[2]
3670
+ if (match[0] === nextLetter) run.push({
3671
+ letter: match[0],
3672
+ text: match[1]
3647
3673
  });
3648
3674
  else {
3649
3675
  flush();
3650
- if (match[1] === "A") run.push({
3651
- letter: match[1],
3652
- text: match[2]
3676
+ if (match[0] === "A") run.push({
3677
+ letter: match[0],
3678
+ text: match[1]
3653
3679
  });
3654
3680
  }
3655
3681
  }
@@ -3794,16 +3820,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
3794
3820
  */
3795
3821
  function ctxSegments(pct, breakdown) {
3796
3822
  if (pct <= 0) return [];
3797
- const total = breakdown === null ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
3823
+ if (breakdown === null) return [{
3824
+ key: "total",
3825
+ cls: "",
3826
+ width: pct
3827
+ }];
3828
+ const bd = breakdown;
3829
+ const total = bd.systemTokens + bd.toolsTokens + bd.messageTokens;
3798
3830
  if (total <= 0) return [{
3799
3831
  key: "total",
3800
3832
  cls: "",
3801
3833
  width: pct
3802
3834
  }];
3803
3835
  return [
3804
- ["sys", breakdown.systemTokens],
3805
- ["tools", breakdown.toolsTokens],
3806
- ["msgs", breakdown.messageTokens]
3836
+ ["sys", bd.systemTokens],
3837
+ ["tools", bd.toolsTokens],
3838
+ ["msgs", bd.messageTokens]
3807
3839
  ].map(([key, tokens]) => ({
3808
3840
  key,
3809
3841
  cls: key,
@@ -4740,8 +4772,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4740
4772
  const claimed = /* @__PURE__ */ new Set();
4741
4773
  const out = [];
4742
4774
  for (const row of rows) {
4743
- const type = row.role === "tool" && row.toolState === "done" && row.resultText !== void 0 ? ARTIFACT_TOOLS[row.text] : void 0;
4744
- if (type === void 0) {
4775
+ const type = row.role === "tool" && row.toolState === "done" && row.resultText !== void 0 && row.text !== void 0 ? ARTIFACT_TOOLS[row.text] : void 0;
4776
+ if (type === void 0 || row.resultText === void 0) {
4745
4777
  out.push(row);
4746
4778
  continue;
4747
4779
  }
@@ -5680,8 +5712,11 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5680
5712
  * does everything. Gestures: single-pointer drag pan, two-pointer pinch
5681
5713
  * anchored at the midpoint, wheel zoom anchored at the cursor, double
5682
5714
  * click/tap toggles fit↔100%. ResizeObserver refits on content/stage size
5683
- * changes (mermaid's async svg). A floating −/%/fit/+ toolbar stays mounted.
5684
- * Shared by the notebook's board tab and the diagram modals.
5715
+ * changes (mermaid's async svg) — through the #12 gates: value-equality on
5716
+ * setSize, a bounds-differ short-circuit on refit, rAF coalescing, and a
5717
+ * recurrence-tripping oscillation breaker (see makeRefitBreaker). A floating
5718
+ * −/%/fit/+ toolbar stays mounted. Shared by the notebook's board tab and
5719
+ * the diagram modals.
5685
5720
  * (lucide glyphs collapse to text/plugin icons; the client stays dependency-
5686
5721
  * free.)
5687
5722
  * @module dsh-plugin-lookatstudy/client/canvasstage
@@ -5690,6 +5725,43 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5690
5725
  const MAX_SCALE = 4;
5691
5726
  /** 工具条步进(手势/滚轮连续,按钮给干脆的一档)。 */
5692
5727
  const STEP = 1.25;
5728
+ /** True when the two bounds readings differ in ANY field — the auto-refit
5729
+ * short-circuit. The pre-fix refit ran fitTransform on every RO callback with
5730
+ * zero hysteresis, so any feedback touching a size re-fired it forever. */
5731
+ function stageBoundsDiffer(a, b) {
5732
+ if (a === null) return true;
5733
+ return a.contentW !== b.contentW || a.contentH !== b.contentH || a.viewW !== b.viewW || a.viewH !== b.viewH;
5734
+ }
5735
+ function makeRefitBreaker(opts = {}) {
5736
+ const windowMs = opts.windowMs ?? 2e3;
5737
+ const now = opts.now ?? (() => performance.now());
5738
+ let seen = [];
5739
+ let tripped = false;
5740
+ return {
5741
+ allow(key) {
5742
+ const t = now();
5743
+ seen = seen.filter((s) => t - s.t < windowMs);
5744
+ if (tripped) return false;
5745
+ if (seen.some((s) => s.key === key)) {
5746
+ tripped = true;
5747
+ return false;
5748
+ }
5749
+ seen.push({
5750
+ t,
5751
+ key
5752
+ });
5753
+ return true;
5754
+ },
5755
+ reset() {
5756
+ seen = [];
5757
+ tripped = false;
5758
+ },
5759
+ tripped() {
5760
+ return tripped;
5761
+ }
5762
+ };
5763
+ }
5764
+ const boundsKey = (b) => `${String(b.contentW)}x${String(b.contentH)}@${String(b.viewW)}x${String(b.viewH)}`;
5693
5765
  function CanvasStage({ children, grid = true, testid }) {
5694
5766
  const stageRef = (0, react.useRef)(null);
5695
5767
  const contentRef = (0, react.useRef)(null);
@@ -5711,6 +5783,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5711
5783
  moved: false
5712
5784
  });
5713
5785
  const lastTapRef = (0, react.useRef)(null);
5786
+ const lastBoundsRef = (0, react.useRef)(null);
5787
+ const breakerRef = (0, react.useRef)(null);
5788
+ if (breakerRef.current === null) breakerRef.current = makeRefitBreaker();
5714
5789
  const bounds = (0, react.useCallback)(() => {
5715
5790
  const stage = stageRef.current;
5716
5791
  const content = contentRef.current;
@@ -5723,32 +5798,62 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5723
5798
  viewH: stage?.clientHeight ?? 0
5724
5799
  };
5725
5800
  }, []);
5726
- const refit = (0, react.useCallback)(() => {
5727
- const b = bounds();
5728
- if (b.contentW === 0 || b.viewW === 0) return;
5801
+ const currentBounds = (0, react.useCallback)(() => ({
5802
+ contentW: contentRef.current?.offsetWidth ?? 0,
5803
+ contentH: contentRef.current?.offsetHeight ?? 0,
5804
+ viewW: stageRef.current?.clientWidth ?? 0,
5805
+ viewH: stageRef.current?.clientHeight ?? 0
5806
+ }), []);
5807
+ const applyFit = (0, react.useCallback)((b) => {
5729
5808
  setTf(fitTransform(b.contentW, b.contentH, b.viewW, b.viewH));
5730
5809
  atFitRef.current = true;
5731
- }, [bounds]);
5810
+ }, []);
5811
+ const refit = (0, react.useCallback)(() => {
5812
+ breakerRef.current?.reset();
5813
+ const b = currentBounds();
5814
+ if (b.contentW === 0 || b.viewW === 0) return;
5815
+ lastBoundsRef.current = b;
5816
+ applyFit(b);
5817
+ }, [currentBounds, applyFit]);
5818
+ const measureNow = (0, react.useCallback)(() => {
5819
+ const content = contentRef.current;
5820
+ if (content === null) return;
5821
+ const w = content.offsetWidth;
5822
+ const h = content.offsetHeight;
5823
+ setSize((prev) => prev !== null && prev.w === w && prev.h === h ? prev : {
5824
+ w,
5825
+ h
5826
+ });
5827
+ const b = currentBounds();
5828
+ if (!stageBoundsDiffer(lastBoundsRef.current, b)) return;
5829
+ lastBoundsRef.current = b;
5830
+ if (b.contentW === 0 || b.viewW === 0) return;
5831
+ if (b.contentW < 120) return;
5832
+ if ((breakerRef.current ?? makeRefitBreaker()).allow(boundsKey(b))) applyFit(b);
5833
+ }, [currentBounds, applyFit]);
5732
5834
  (0, react.useEffect)(() => {
5733
5835
  const stage = stageRef.current;
5734
5836
  const content = contentRef.current;
5735
5837
  if (stage === null || content === null) return;
5736
- const measure = () => {
5737
- setSize({
5738
- w: content.offsetWidth,
5739
- h: content.offsetHeight
5838
+ measureNow();
5839
+ let raf = 0;
5840
+ const onRo = () => {
5841
+ if (raf !== 0) return;
5842
+ raf = requestAnimationFrame(() => {
5843
+ raf = 0;
5844
+ measureNow();
5740
5845
  });
5741
- refit();
5742
5846
  };
5743
- measure();
5744
- const ro = new ResizeObserver(measure);
5847
+ const ro = new ResizeObserver(onRo);
5745
5848
  ro.observe(content);
5746
5849
  ro.observe(stage);
5747
5850
  return () => {
5748
5851
  ro.disconnect();
5852
+ if (raf !== 0) cancelAnimationFrame(raf);
5749
5853
  };
5750
- }, [refit]);
5854
+ }, [measureNow]);
5751
5855
  const zoomBy = (0, react.useCallback)((factor, ax, ay) => {
5856
+ breakerRef.current?.reset();
5752
5857
  const b = bounds();
5753
5858
  const cx = ax ?? b.viewW / 2;
5754
5859
  const cy = ay ?? b.viewH / 2;
@@ -5960,7 +6065,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5960
6065
  let seen;
5961
6066
  try {
5962
6067
  const raw = storage.getItem(seenArtifactsKey(lessonId));
5963
- seen = new Set(Array.isArray(JSON.parse(raw ?? "[]")) ? JSON.parse(raw ?? "[]") : []);
6068
+ const parsed = JSON.parse(raw ?? "[]");
6069
+ seen = new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []);
5964
6070
  } catch {
5965
6071
  seen = /* @__PURE__ */ new Set();
5966
6072
  }
@@ -6071,12 +6177,32 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6071
6177
  to: a.lineEnd
6072
6178
  })}`), a.note))));
6073
6179
  }
6180
+ /** The 放大查看 modal's mermaid host — rendered ONCE per mermaid source. The
6181
+ * raw-ref-callback version re-ran renderMermaidInto on every parent render
6182
+ * (each 3s state poll), and its el.textContent='' clear + async re-render
6183
+ * re-mounted the svg twice per tick — the #12 flicker. A stable effect deps
6184
+ * chain (string mermaid + useCallback'd onFail) breaks the loop at the
6185
+ * source; the CanvasStage gates are the second line of defense. */
6186
+ function DiagramModalDiagram({ mermaid, onFail }) {
6187
+ const bodyRef = (0, react.useRef)(null);
6188
+ (0, react.useEffect)(() => {
6189
+ if (bodyRef.current === null) return;
6190
+ renderMermaidInto(bodyRef.current, mermaid, onFail);
6191
+ }, [mermaid, onFail]);
6192
+ return (0, react.createElement)("div", {
6193
+ className: "lks14-modal-diagram",
6194
+ ref: bodyRef
6195
+ });
6196
+ }
6074
6197
  /** Mermaid diagram card with an expand modal (upstream DiagramViewerModal). */
6075
6198
  function DiagramCard({ artifact }) {
6076
6199
  const mermaid = typeof artifact.data.mermaid === "string" ? artifact.data.mermaid : "";
6077
6200
  const bodyRef = (0, react.useRef)(null);
6078
6201
  const [failed, setFailed] = (0, react.useState)(false);
6079
6202
  const [expanded, setExpanded] = (0, react.useState)(false);
6203
+ const onFail = (0, react.useCallback)(() => {
6204
+ setFailed(true);
6205
+ }, []);
6080
6206
  (0, react.useEffect)(() => {
6081
6207
  if (!expanded) return;
6082
6208
  const onKey = (e) => {
@@ -6089,10 +6215,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6089
6215
  }, [expanded]);
6090
6216
  (0, react.useEffect)(() => {
6091
6217
  if (bodyRef.current === null) return;
6092
- renderMermaidInto(bodyRef.current, mermaid, () => {
6093
- setFailed(true);
6094
- });
6095
- }, [mermaid]);
6218
+ renderMermaidInto(bodyRef.current, mermaid, onFail);
6219
+ }, [mermaid, onFail]);
6096
6220
  const body = failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)("div", {
6097
6221
  className: "lks-acard-diagram",
6098
6222
  ref: bodyRef
@@ -6121,13 +6245,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6121
6245
  onClick: () => {
6122
6246
  setExpanded(false);
6123
6247
  }
6124
- }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)(CanvasStage, { testid: "diagram-modal-stage" }, (0, react.createElement)("div", {
6125
- className: "lks14-modal-diagram",
6126
- ref: (el) => {
6127
- if (el !== null) renderMermaidInto(el, mermaid, () => {
6128
- setFailed(true);
6129
- });
6130
- }
6248
+ }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), failed ? (0, react.createElement)("pre", { className: "lks-acard-code" }, mermaid) : (0, react.createElement)(CanvasStage, { testid: "diagram-modal-stage" }, (0, react.createElement)(DiagramModalDiagram, {
6249
+ mermaid,
6250
+ onFail
6131
6251
  })))) : null);
6132
6252
  }
6133
6253
  /** The opening guess: two big options, click = pick (sent to the tutor, who
@@ -7307,7 +7427,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7307
7427
  key: opt.letter,
7308
7428
  className: "lks14-opt",
7309
7429
  onClick: () => {
7310
- interactive.send(tr("quiz.answer", {
7430
+ interactive?.send(tr("quiz.answer", {
7311
7431
  letter: opt.letter,
7312
7432
  text: opt.text
7313
7433
  }));
@@ -7980,6 +8100,33 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7980
8100
  * search-and-jump (the dashboard search API), course switch, review, start
7981
8101
  * studying — all funneling the panel's own actions.
7982
8102
  */
8103
+ /** The cmap modal's diagram host — rendered ONCE per (title, concepts) VALUE.
8104
+ * The raw-ref-callback version re-ran renderLessonConceptMap on every parent
8105
+ * render (each 3s state poll recreates the feed-derived concepts array, so
8106
+ * even a memo-by-identity effect would re-fire); the dataset key compares by
8107
+ * value — poll re-renders no-op, a real mastery change re-renders. Same #12
8108
+ * family as the diagram modal. */
8109
+ function CmapModalDiagram({ title, concepts }) {
8110
+ const ref = (0, react.useRef)(null);
8111
+ (0, react.useEffect)(() => {
8112
+ const el = ref.current;
8113
+ if (el === null) return;
8114
+ const key = `${title}\u0000${concepts.map((c) => `${c.title}:${String(c.masteryPct)}`).join("")}`;
8115
+ if (el.dataset.cmapKey === key) return;
8116
+ el.dataset.cmapKey = key;
8117
+ el.textContent = "";
8118
+ renderLessonConceptMap(el, title, concepts.map((c) => ({
8119
+ title: c.title,
8120
+ masteryPct: c.masteryPct
8121
+ }))).catch(() => {
8122
+ el.textContent = tr("bb.fallback.cmap");
8123
+ });
8124
+ });
8125
+ return (0, react.createElement)("div", {
8126
+ className: "lks14-modal-diagram",
8127
+ ref
8128
+ });
8129
+ }
7983
8130
  function CommandPalette({ searchLessons, courses, onClose, onLesson, onCourse, onReview, onStudy }) {
7984
8131
  const [query, setQuery] = (0, react.useState)("");
7985
8132
  const [lessons, setLessons] = (0, react.useState)([]);
@@ -9669,7 +9816,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9669
9816
  (0, react.useEffect)(() => {
9670
9817
  if (tab !== "teach" || proseRef.current === null || lesson === null) return;
9671
9818
  enhanceRendered(proseRef.current).catch(() => {}).finally(() => {
9672
- applyHighlights(proseRef.current, recordQuotes);
9819
+ if (proseRef.current !== null) applyHighlights(proseRef.current, recordQuotes);
9673
9820
  setEnhanceTick(Date.now());
9674
9821
  });
9675
9822
  }, [tab, lesson?.html]);
@@ -9678,7 +9825,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9678
9825
  locateNoteQuote(pendingLocate, proseRef.current);
9679
9826
  setPendingLocate(null);
9680
9827
  }, [pendingLocate, tab]);
9681
- const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: "lks14-notebody" }, (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
9828
+ const body = lesson === null ? (0, react.createElement)("div", { className: "lks14-empty" }, tr("bb.empty"), (0, react.createElement)("br"), tr("bb.empty.hint")) : (0, react.createElement)("div", { className: `lks14-notebody${tab === "board" ? " lks14-notebody-fill" : ""}` }, (0, react.createElement)("div", { className: "lks14-viewtabs" }, (0, react.createElement)("button", {
9682
9829
  className: `lks14-viewtab${tab === "teach" ? " on" : ""}`,
9683
9830
  "aria-pressed": String(tab === "teach"),
9684
9831
  onClick: () => {
@@ -9722,7 +9869,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9722
9869
  onClick: () => {
9723
9870
  setRateBusy(true);
9724
9871
  recordReview(lesson.lessonId, Number(q)).then(() => {
9725
- const rateEl = document.querySelector(`[data-lks-rate="${lesson.lessonId}"]`);
9872
+ const rateEl = document.querySelector(`[data-lks-rate="${CSS.escape(lesson.lessonId)}"]`);
9726
9873
  const rr = Number(q) >= 4 && rateEl !== null ? rateEl.getBoundingClientRect() : null;
9727
9874
  celebrate(Number(q) >= 4 ? "correct" : "wrong", rr !== null ? { origin: {
9728
9875
  x: rr.right - 48,
@@ -9830,19 +9977,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9830
9977
  onClick: () => {
9831
9978
  setCmapExpanded(false);
9832
9979
  }
9833
- }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), (0, react.createElement)(CanvasStage, { testid: "cmap-modal-stage" }, (0, react.createElement)("div", {
9834
- className: "lks14-modal-diagram",
9835
- ref: (el) => {
9836
- if (el !== null) {
9837
- el.textContent = "";
9838
- renderLessonConceptMap(el, lesson.title, lesson.concepts.map((c) => ({
9839
- title: c.title,
9840
- masteryPct: c.masteryPct
9841
- }))).catch(() => {
9842
- el.textContent = tr("bb.fallback.cmap");
9843
- });
9844
- }
9845
- }
9980
+ }, (0, react.createElement)(IconCloseFill16, { size: 13 }))), (0, react.createElement)(CanvasStage, { testid: "cmap-modal-stage" }, (0, react.createElement)(CmapModalDiagram, {
9981
+ title: lesson.title,
9982
+ concepts: lesson.concepts.map((c) => ({
9983
+ title: c.title,
9984
+ masteryPct: c.masteryPct
9985
+ }))
9846
9986
  })))) : null) : (0, react.createElement)("div", { className: "lks14-zones" }, (() => {
9847
9987
  const understand = artifacts.filter((a) => a.artifactType === "compare_table" || a.artifactType === "diagram" || a.artifactType === "code_walkthrough");
9848
9988
  if (understand.length === 0) return null;
@@ -9893,10 +10033,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9893
10033
  "data-tooltip": tr("note.locate"),
9894
10034
  "aria-label": tr("note.locate"),
9895
10035
  onClick: () => {
9896
- if (tab !== "teach") {
9897
- setTab("teach");
9898
- setPendingLocate(n.quote ?? "");
9899
- } else locateNoteQuote(n.quote ?? "", proseRef.current);
10036
+ setTab("teach");
10037
+ setPendingLocate(n.quote ?? "");
9900
10038
  }
9901
10039
  }, (0, react.createElement)(IconGoalOutline16, { size: 12 })) : null, (0, react.createElement)("button", {
9902
10040
  className: "lks-note-act",