dsh-plugin-lookatstudy 0.25.4 → 0.26.1

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
@@ -401,6 +401,19 @@ window.__ModuleLoader__.load({
401
401
  registration; the span class is the no-Highlight-API fallback) */
402
402
  ::highlight(lks-reading){background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong)}
403
403
  .lks-ui .lks14-reading-mark{background:rgb(var(--brand-rgb)/0.28);color:var(--ink-strong);border-radius:3px}
404
+ /* v0.35.1-alignment: persistent note highlights ride the same registration
405
+ route (zero DOM mutation) — same skin as the mark.lks-hl fallback */
406
+ .lks-ui ::highlight(lks-note-hl){background:rgb(var(--gold-rgb)/0.25);border-bottom:2px solid var(--gold)}
407
+ /* v0.37-alignment: the progression-boundary card + the rail pulse ring the
408
+ 「指出下一课」 action arms (bot points, user drives — nothing auto-switches) */
409
+ .lks-ui .lks14-boundary{display:flex;align-items:center;gap:10px;margin:10px 20px 4px;padding:10px 12px;border:1px solid rgb(var(--gold-rgb)/0.5);background:rgb(var(--gold-rgb)/0.08);border-radius:10px;flex:none;color:var(--gold-dark)}
410
+ .lks-ui .lks14-boundary-text{flex:1;min-width:0}
411
+ .lks-ui .lks14-boundary-title{font-weight:700;font-size:13.5px;color:var(--ink-strong)}
412
+ .lks-ui .lks14-boundary-sub{font-size:12px;color:var(--ink-muted);margin-top:2px}
413
+ .lks-ui .lks14-boundary-x{flex:none;background:none;border:none;color:var(--ink-muted);cursor:pointer;font-size:13px;padding:4px 6px}
414
+ .lks-ui .lks14-boundary-x:hover{color:var(--ink-strong)}
415
+ .lks-ui .lks-lessorow.next-hint{outline:2px dashed var(--gold);outline-offset:1px;animation:lks-hint-pulse 1.6s ease-in-out infinite}
416
+ @keyframes lks-hint-pulse{0%,100%{outline-color:rgb(var(--gold-rgb)/0.95)}50%{outline-color:rgb(var(--gold-rgb)/0.25)}}
404
417
  /* #10-followup: the reading column is height:auto (the note col scrolls), so
405
418
  .lks14-board's height:100% resolved against a content-sized parent and the
406
419
  stage collapsed to a ~90px sliver — the never-upscale contain fit then
@@ -1774,6 +1787,64 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1774
1787
  }));
1775
1788
  }
1776
1789
  //#endregion
1790
+ //#region src/client/stage-session.ts
1791
+ /** The plugin's private retention label (never the view owner's 'mainView'). */
1792
+ const STAGE_SOURCE = "lookatstudy";
1793
+ /** Which staging generation the host speaks ('retain' wins when both exist). */
1794
+ function stagingMode(sessions) {
1795
+ return typeof sessions.retain === "function" ? "retain" : "open";
1796
+ }
1797
+ let held = null;
1798
+ /**
1799
+ * Stage `sessionId` for observation + addressability.
1800
+ * alpha: holds the plugin's reference. rc: `open()` navigation. Throws
1801
+ * whatever the host face throws — callers keep their existing catch policy.
1802
+ */
1803
+ function stageSession(sessions, sessionId) {
1804
+ if (stagingMode(sessions) === "retain") {
1805
+ if (held !== null && held.sessionId === sessionId) return;
1806
+ const previous = held;
1807
+ held = sessions.retain(sessionId, { source: STAGE_SOURCE });
1808
+ previous?.release();
1809
+ } else sessions.open?.(sessionId);
1810
+ }
1811
+ /** Release the held alpha reference (plugin dispose — across open/close
1812
+ * toggles the reference stays held, mirroring rc's sticky current). */
1813
+ function unstageSession() {
1814
+ held?.release();
1815
+ held = null;
1816
+ }
1817
+ /**
1818
+ * Hold a TRANSIENT alpha reference for an effect's lifetime so scope/binding
1819
+ * resolve for an un-staged session (E1/E6 projections, thread rename).
1820
+ * rc: a no-op closer — listed ids are addressable there without help, and
1821
+ * calling open() from inside an effect would trip the rc hand-back.
1822
+ * alpha: undefined only when the retain is refused (treat as feature-absent).
1823
+ */
1824
+ function acquireStageReference(sessions, sessionId) {
1825
+ if (sessions.retain === void 0) return () => {};
1826
+ let ref;
1827
+ try {
1828
+ ref = sessions.retain(sessionId, { source: STAGE_SOURCE });
1829
+ } catch {
1830
+ return;
1831
+ }
1832
+ return () => {
1833
+ ref?.release();
1834
+ ref = void 0;
1835
+ };
1836
+ }
1837
+ /**
1838
+ * Derive the host main-view selection from a list snapshot the way
1839
+ * ui-workspace does: alpha removed `list.current`, and the view owner's
1840
+ * `mainView` retention is the only public trace of the main selection.
1841
+ * Pure; rc snapshots carry no `retainedBy` and read as no selection.
1842
+ */
1843
+ function mainSelectionOf(byId) {
1844
+ if (byId === void 0) return void 0;
1845
+ for (const entry of Object.entries(byId)) if ((entry[1]?.retainedBy?.mainView ?? 0) > 0) return entry[0];
1846
+ }
1847
+ //#endregion
1777
1848
  //#region src/client/data.ts
1778
1849
  /**
1779
1850
  * Shared client-side study store: one module-level poller over the host
@@ -1990,6 +2061,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1990
2061
  });
1991
2062
  this.refresh();
1992
2063
  }
2064
+ /** Upstream v0.36: save the learner's OWN profile edit (the human path —
2065
+ * this is also what marks older AI suggestions stale). */
2066
+ async saveProfile(patch) {
2067
+ await fetchJson("/lookatstudy/api/profile", {
2068
+ method: "POST",
2069
+ headers: { "content-type": "application/json" },
2070
+ body: JSON.stringify(patch)
2071
+ });
2072
+ this.refresh();
2073
+ }
2074
+ /** Accept or ignore one AI profile suggestion (the settings-page consumer). */
2075
+ async resolveProfileProposal(id, accept) {
2076
+ await fetchJson("/lookatstudy/api/profile/resolve", {
2077
+ method: "POST",
2078
+ headers: { "content-type": "application/json" },
2079
+ body: JSON.stringify({
2080
+ id,
2081
+ accept
2082
+ })
2083
+ });
2084
+ this.refresh();
2085
+ }
2086
+ /** Anti-farming (upstream v0.35): a locally-judged practice card is human
2087
+ * grading — report it so the tutor-only mastery cap lifts on the lesson. */
2088
+ async reportPracticeGraded(lessonId) {
2089
+ await fetchJson("/lookatstudy/api/practice-graded", {
2090
+ method: "POST",
2091
+ headers: { "content-type": "application/json" },
2092
+ body: JSON.stringify({ lessonId })
2093
+ }).catch(() => void 0);
2094
+ }
1993
2095
  /** Delete one notebook entry from a lesson's Cornell zones. */
1994
2096
  async deleteNote(lessonId, noteId) {
1995
2097
  await fetchJson("/lookatstudy/api/note/delete", {
@@ -2159,6 +2261,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2159
2261
  setFocus: studyStore.setFocus.bind(studyStore),
2160
2262
  searchLessons: studyStore.searchLessons.bind(studyStore),
2161
2263
  tts: studyStore.tts.bind(studyStore),
2264
+ saveProfile: studyStore.saveProfile.bind(studyStore),
2265
+ resolveProfileProposal: studyStore.resolveProfileProposal.bind(studyStore),
2266
+ reportPracticeGraded: studyStore.reportPracticeGraded.bind(studyStore),
2162
2267
  setCourseScope: studyStore.setCourseScope.bind(studyStore),
2163
2268
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2164
2269
  deleteNote: studyStore.deleteNote.bind(studyStore),
@@ -2255,6 +2360,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2255
2360
  "proposal.banner": "《{lesson}》可以提前毕业了",
2256
2361
  "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
2257
2362
  "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
2363
+ "boundary.done": "本课完成,皇冠到手!",
2364
+ "boundary.course.done": "整门课程完成!",
2365
+ "boundary.next.sub": "下一课是《{title}》——在课程栏点它进入。",
2366
+ "boundary.thread.sub": "下一课是《{title}》——点开始,就在这条线上接着讲。",
2367
+ "boundary.thread.point": "开始下一课",
2368
+ "boundary.thread.send": "这一课完成了,我们开始下一课《{title}》吧。",
2369
+ "boundary.last.sub": "全部课时都已掌握,复习会按计划回来。",
2370
+ "boundary.point": "在课程栏指出下一课",
2371
+ "boundary.dismiss": "收起",
2258
2372
  "tutor.thinking": "导师思考中…",
2259
2373
  "header.xp": "经验值 {xp}",
2260
2374
  "header.streak": "连续学习天数",
@@ -2489,7 +2603,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2489
2603
  "settings.stats.courses": "课程 {count} 个",
2490
2604
  "settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
2491
2605
  "settings.stats.today": "今日 {xp}/{goal}",
2492
- "settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
2606
+ "settings.stats.streak": "连续 {days} 天 · 最长 {best} 天 · 冻结 {freeze}",
2607
+ "settings.stats.mastered": "已掌握 {count} 课 · 待复习 {due} 课",
2608
+ "settings.profile": "学习者画像",
2609
+ "settings.profile.hint": "只属于你的背景事实,用来调整怎么教——不进任何评分。AI 的建议也在这里处理,不打断对话。",
2610
+ "settings.profile.name": "称呼",
2611
+ "settings.profile.mbti": "MBTI(快捷入口,自动展开为风格四维)",
2612
+ "settings.profile.interests": "兴趣点(顿号/逗号分隔,最多 8 个)",
2613
+ "settings.profile.interests.ph": "例如:天文、做菜、悬疑小说",
2614
+ "settings.profile.note": "想对导师说的话",
2615
+ "settings.profile.motive": "学习动机(只有你自己能设置)",
2616
+ "settings.profile.unset": "未设置",
2617
+ "settings.profile.save": "保存画像",
2618
+ "settings.profile.private": "画像只存本地 state.json,不会上传。",
2619
+ "settings.style.start": "讲解起点",
2620
+ "settings.style.analogy": "先具体实例建直觉",
2621
+ "settings.style.framework": "先框架后细节",
2622
+ "settings.style.interaction": "互动密度",
2623
+ "settings.style.dialogue": "边讲边问",
2624
+ "settings.style.lecture": "讲完一节再答疑",
2625
+ "settings.style.feedback": "反馈风格",
2626
+ "settings.style.direct": "直接纠错",
2627
+ "settings.style.encouraging": "先肯定再指正",
2628
+ "settings.style.pacing": "节奏",
2629
+ "settings.style.sequential": "按顺序推进",
2630
+ "settings.style.exploratory": "允许跳着学",
2631
+ "settings.suggest.head": "AI 建议的",
2632
+ "settings.suggest.apply": "采纳",
2633
+ "settings.suggest.ignore": "忽略",
2493
2634
  "settings.about": "关于",
2494
2635
  "settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
2495
2636
  "settings.stateFile": "状态文件",
@@ -2626,6 +2767,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2626
2767
  "proposal.banner": "{lesson} is ready to graduate early",
2627
2768
  "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
2628
2769
  "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
2770
+ "boundary.done": "Lesson complete — crown earned!",
2771
+ "boundary.course.done": "Course complete!",
2772
+ "boundary.next.sub": "Next up: {title} — click it in the course rail to continue.",
2773
+ "boundary.thread.sub": "Next: {title} — continue right here in this thread.",
2774
+ "boundary.thread.point": "Start the next lesson",
2775
+ "boundary.thread.send": "This lesson is wrapped up — let's start the next one, {title}.",
2776
+ "boundary.last.sub": "Every lesson is mastered; reviews will come back on schedule.",
2777
+ "boundary.point": "Point at the next lesson",
2778
+ "boundary.dismiss": "Dismiss",
2629
2779
  "tutor.thinking": "The tutor is thinking…",
2630
2780
  "header.xp": "{xp} XP",
2631
2781
  "header.streak": "Day streak",
@@ -2860,7 +3010,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2860
3010
  "settings.stats.courses": "{count} courses",
2861
3011
  "settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
2862
3012
  "settings.stats.today": "Today {xp}/{goal}",
2863
- "settings.stats.streak": "Streak {days}d (best {best}, {freeze} freeze left)",
3013
+ "settings.stats.streak": "Streak {days} days · best {best} · freezes {freeze}",
3014
+ "settings.stats.mastered": "{count} lessons mastered · {due} due",
3015
+ "settings.profile": "Learner profile",
3016
+ "settings.profile.hint": "Background facts only you can declare — they shape HOW you are taught, never any scoring. AI suggestions resolve here, never in the chat.",
3017
+ "settings.profile.name": "Name",
3018
+ "settings.profile.mbti": "MBTI (shortcut — expands into the style dims)",
3019
+ "settings.profile.interests": "Interests (comma-separated, max 8)",
3020
+ "settings.profile.interests.ph": "e.g. astronomy, cooking, mystery novels",
3021
+ "settings.profile.note": "Note to the tutor",
3022
+ "settings.profile.motive": "Learning motive (only you can set this)",
3023
+ "settings.profile.unset": "Not set",
3024
+ "settings.profile.save": "Save profile",
3025
+ "settings.profile.private": "Stored in your local state.json only — never uploaded.",
3026
+ "settings.style.start": "Explanation start",
3027
+ "settings.style.analogy": "Concrete examples first",
3028
+ "settings.style.framework": "Framework first",
3029
+ "settings.style.interaction": "Interaction density",
3030
+ "settings.style.dialogue": "Questions as we go",
3031
+ "settings.style.lecture": "Lecture first, Q&A after",
3032
+ "settings.style.feedback": "Feedback style",
3033
+ "settings.style.direct": "Direct corrections",
3034
+ "settings.style.encouraging": "Encouraging corrections",
3035
+ "settings.style.pacing": "Pacing",
3036
+ "settings.style.sequential": "Sequential",
3037
+ "settings.style.exploratory": "Exploratory",
3038
+ "settings.suggest.head": "AI suggested",
3039
+ "settings.suggest.apply": "Accept",
3040
+ "settings.suggest.ignore": "Ignore",
2864
3041
  "settings.about": "About",
2865
3042
  "settings.about.hint": "The version of the running build — click to see its release notes.",
2866
3043
  "settings.stateFile": "State file",
@@ -4764,7 +4941,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4764
4941
  nodes
4765
4942
  };
4766
4943
  }
4767
- function offsetsToRange(model, start, end) {
4944
+ function offsetsToRange$1(model, start, end) {
4768
4945
  const range = document.createRange();
4769
4946
  let setStart = false;
4770
4947
  let setEnd = false;
@@ -4823,7 +5000,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4823
5000
  if (m === null && from > 0) m = matchSentenceAligned(model.text, trimmed, 0);
4824
5001
  if (m === null) return null;
4825
5002
  readingCursors.set(container, m[1]);
4826
- const range = offsetsToRange(model, m[0], m[1]);
5003
+ const range = offsetsToRange$1(model, m[0], m[1]);
4827
5004
  if (range === null) return null;
4828
5005
  const cssLike = CSS;
4829
5006
  if (typeof Highlight !== "undefined" && cssLike.highlights !== void 0) try {
@@ -5249,6 +5426,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5249
5426
  };
5250
5427
  }
5251
5428
  /**
5429
+ * Tool-error visibility (upstream v0.37.1 toolErrorVisibility, part-accumulator):
5430
+ * an error tool row that is SUPERSEDED by a later same-tool call within the
5431
+ * same turn (no learner message in between) is dropped — weak models fail a
5432
+ * zod-validated input on the first try and succeed on the retry (observed
5433
+ * live: the quiz error block flashed, then the quiz card landed), and the
5434
+ * stale transient failure is pure noise. A real failure — no successor —
5435
+ * stays visible; nothing is silently swallowed. Pure.
5436
+ */
5437
+ function hideSupersededToolErrors(rows) {
5438
+ const out = [];
5439
+ for (let i = 0; i < rows.length; i++) {
5440
+ const row = rows[i];
5441
+ if (row.role === "tool" && row.toolState === "error") {
5442
+ let superseded = false;
5443
+ for (let j = i + 1; j < rows.length; j++) {
5444
+ const later = rows[j];
5445
+ if (later.role === "user") break;
5446
+ if (later.role === "tool" && later.text === row.text && (later.toolState === "loading" || later.toolState === "done")) {
5447
+ superseded = true;
5448
+ break;
5449
+ }
5450
+ }
5451
+ if (superseded) continue;
5452
+ }
5453
+ out.push(row);
5454
+ }
5455
+ return out;
5456
+ }
5457
+ /**
5252
5458
  * Fold one event-window snapshot into ordered chat rows. The transient
5253
5459
  * `text-delta` chunks of the most recent attempt accumulate into a single
5254
5460
  * streaming row (the durable `assistant/message` replaces it on settlement).
@@ -5369,7 +5575,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5369
5575
  running: true
5370
5576
  });
5371
5577
  }
5372
- return rows;
5578
+ return hideSupersededToolErrors(rows);
5373
5579
  }
5374
5580
  /**
5375
5581
  * Parse an ask_user_question call's arguments into the answerable row payload
@@ -6058,6 +6264,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6058
6264
  if (!showScore || !allAnswered || hookSent) return;
6059
6265
  setHookSent(true);
6060
6266
  onFinished?.(score.correct === score.total);
6267
+ studyStore.reportPracticeGraded(lessonId);
6061
6268
  const next = {
6062
6269
  answers: [...progress.answers],
6063
6270
  done: true
@@ -6924,11 +7131,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6924
7131
  nodes
6925
7132
  };
6926
7133
  }
6927
- /** The mark class applied to persisted highlights. */
7134
+ /** The mark class applied to persisted highlights (DOM fallback channel only). */
6928
7135
  const HIGHLIGHT_MARK = "lks-hl";
6929
7136
  /**
7137
+ * Persistent-note highlight registry name — the CSS Custom Highlight API
7138
+ * channel (upstream v0.35.1 root fix): the old scheme split text nodes and
7139
+ * inserted <mark>, rewriting DOM a framework may reconcile over — any later
7140
+ * re-render could throw a DOMException into the error boundary. The registry
7141
+ * channel registers Ranges in CSS.highlights and touches ZERO DOM nodes.
7142
+ * Styled by the `::highlight(lks-note-hl)` rule in the upstream skin.
7143
+ */
7144
+ const NOTE_HIGHLIGHT_NAME = "lks-note-hl";
7145
+ /** Whether the registration channel exists (Highlight ctor + CSS.highlights). */
7146
+ function supportsHighlightMarks() {
7147
+ return typeof Highlight !== "undefined" && !!CSS.highlights;
7148
+ }
7149
+ /** Live note ranges: quote → Range. CSS.highlights is a document-level
7150
+ * singleton, so every container shares one Highlight; dead ranges (nodes
7151
+ * detached by a re-render of the source) are pruned on rebuild so the map
7152
+ * cannot grow without bound. */
7153
+ const liveNoteRanges = /* @__PURE__ */ new Map();
7154
+ const noteRangeOwners = /* @__PURE__ */ new WeakMap();
7155
+ function rebuildNoteHighlight() {
7156
+ const cssLike = CSS;
7157
+ for (const [quote, r] of liveNoteRanges) if (!r.startContainer.isConnected) liveNoteRanges.delete(quote);
7158
+ try {
7159
+ if (liveNoteRanges.size === 0) cssLike.highlights?.delete(NOTE_HIGHLIGHT_NAME);
7160
+ else cssLike.highlights?.set(NOTE_HIGHLIGHT_NAME, new Highlight(...liveNoteRanges.values()));
7161
+ } catch {}
7162
+ }
7163
+ /**
6930
7164
  * Apply one highlight range to the live DOM: split the boundary text nodes
6931
7165
  * and wrap the covering segments in marks. Returns the created marks.
7166
+ * (The DOM fallback channel — only reached when the Highlight API is absent.)
6932
7167
  */
6933
7168
  function wrapRange(model, start, end) {
6934
7169
  const marks = [];
@@ -6949,12 +7184,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6949
7184
  }
6950
7185
  /**
6951
7186
  * Render the persisted highlights for one lesson: each record-zone note with a
6952
- * quote anchors a mark. Idempotent per call — call on a freshly rendered
6953
- * (pre-mark) container; the model skips existing marks so re-entry is safe.
6954
- * @returns how many notes got at least one mark.
7187
+ * quote anchors a highlight. Registry channel first (zero DOM mutation — a
7188
+ * framework re-render can never hit a foreign node); the mark-wrapping
7189
+ * fallback only runs on engines without CSS.highlights. Idempotent per call —
7190
+ * call on a freshly rendered container; re-entry replaces this container's
7191
+ * registered ranges first.
7192
+ * @returns how many notes got at least one highlight.
6955
7193
  */
6956
7194
  function applyHighlights(container, quotes) {
6957
7195
  let applied = 0;
7196
+ if (supportsHighlightMarks()) {
7197
+ const owned = noteRangeOwners.get(container);
7198
+ if (owned !== void 0) for (const quote of owned) liveNoteRanges.delete(quote);
7199
+ const hits = /* @__PURE__ */ new Map();
7200
+ for (const quote of quotes) {
7201
+ const trimmed = quote.trim();
7202
+ if (trimmed.length < 2) continue;
7203
+ const model = getTextModel(container);
7204
+ const located = locateInModel(model, trimmed, void 0);
7205
+ if (located === null) continue;
7206
+ const range = offsetsToRange(model, located.start, located.end);
7207
+ if (range === null) continue;
7208
+ hits.set(trimmed, range);
7209
+ liveNoteRanges.set(trimmed, range);
7210
+ applied += 1;
7211
+ }
7212
+ noteRangeOwners.set(container, new Set(hits.keys()));
7213
+ rebuildNoteHighlight();
7214
+ return applied;
7215
+ }
6958
7216
  for (const quote of quotes) {
6959
7217
  const trimmed = quote.trim();
6960
7218
  if (trimmed.length < 2) continue;
@@ -6965,6 +7223,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6965
7223
  }
6966
7224
  return applied;
6967
7225
  }
7226
+ /** Build one Range across the model's text nodes (null when the browser
7227
+ * refuses — e.g. a detached node between model build and call). */
7228
+ function offsetsToRange(model, start, end) {
7229
+ const segments = planSegments(model.nodes, start, end);
7230
+ if (segments.length === 0) return null;
7231
+ const first = segments[0];
7232
+ const last = segments[segments.length - 1];
7233
+ try {
7234
+ const range = new Range();
7235
+ range.setStart(model.nodes[first.index].node, first.localStart);
7236
+ range.setEnd(model.nodes[last.index].node, last.localEnd);
7237
+ return range;
7238
+ } catch {
7239
+ return null;
7240
+ }
7241
+ }
6968
7242
  //#endregion
6969
7243
  //#region src/client/maprail.tsx
6970
7244
  /**
@@ -6995,17 +7269,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6995
7269
  if (lesson.status === "in_progress") return lesson.masteryPct ?? 0;
6996
7270
  return null;
6997
7271
  }
6998
- /**
6999
- * A section's world (D6, upstream World = study | practice): the plugin's
7000
- * state flattens the design's per-lesson world into lesson kinds, so a
7001
- * section is the practice world iff it is non-empty and purely practice
7002
- * (the design protocol marks sections, keeping them homogeneous).
7003
- */
7272
+ function nextStudyAfterView(courses, lessonId) {
7273
+ for (const course of courses) for (let si = 0; si < course.sections.length; si++) {
7274
+ const lessons = course.sections[si].lessons;
7275
+ const li = lessons.findIndex((l) => l.id === lessonId);
7276
+ if (li < 0) continue;
7277
+ for (let j = li + 1; j < lessons.length; j++) if (lessons[j].kind === "study") return {
7278
+ id: lessons[j].id,
7279
+ title: lessons[j].title
7280
+ };
7281
+ for (let s = si + 1; s < course.sections.length; s++) {
7282
+ const first = course.sections[s].lessons.find((l) => l.kind === "study");
7283
+ if (first !== void 0) return {
7284
+ id: first.id,
7285
+ title: first.title
7286
+ };
7287
+ }
7288
+ return null;
7289
+ }
7290
+ return null;
7291
+ }
7004
7292
  function sectionWorldOf(section) {
7005
7293
  return section.lessons.length > 0 && section.lessons.every((l) => l.kind === "practice") ? "practice" : "study";
7006
7294
  }
7007
7295
  /** One section: a quiet header toggle + the lesson rows. */
7008
- function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId }) {
7296
+ function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId, hintId }) {
7009
7297
  const lessons = section.lessons;
7010
7298
  const done = lessons.filter((l) => l.status === "mastered").length;
7011
7299
  return (0, react.createElement)("section", { className: "lks-railsec" }, (0, react.createElement)("button", {
@@ -7022,7 +7310,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7022
7310
  return (0, react.createElement)("button", {
7023
7311
  key: lesson.id,
7024
7312
  type: "button",
7025
- className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}`,
7313
+ className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}${hintId !== void 0 && hintId !== null && hintId === lesson.id ? " next-hint" : ""}`,
7026
7314
  "data-node-id": lesson.id,
7027
7315
  "aria-disabled": locked || void 0,
7028
7316
  "data-tooltip": `${lesson.title} — ${stateWord}`,
@@ -7864,6 +8152,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7864
8152
  }
7865
8153
  }
7866
8154
  /** C8: the most recent decided proposal's badge payload (render order preserved). */
8155
+ /** Upstream v0.37 boundary card: lessons whose card the learner dismissed
8156
+ * (persisted so a remount does not re-nag about an already-mastered lesson). */
8157
+ const BOUNDARY_DISMISS_KEY = "lks-boundary-dismissed";
8158
+ function boundaryDismissedLessons() {
8159
+ try {
8160
+ const raw = JSON.parse(localStorage.getItem(BOUNDARY_DISMISS_KEY) ?? "[]");
8161
+ return new Set(Array.isArray(raw) ? raw.filter((s) => typeof s === "string") : []);
8162
+ } catch {
8163
+ return /* @__PURE__ */ new Set();
8164
+ }
8165
+ }
7867
8166
  function decidedBadgeOf(decided) {
7868
8167
  const entries = Object.values(decided);
7869
8168
  return entries.length > 0 ? entries[entries.length - 1] : null;
@@ -8141,6 +8440,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8141
8440
  const [draft, setDraft] = (0, react.useState)("");
8142
8441
  const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
8143
8442
  const lesson = data?.lesson ?? null;
8443
+ const [boundary, setBoundary] = (0, react.useState)(null);
8444
+ const [nextHint, setNextHint] = (0, react.useState)(null);
8445
+ const prevStatusRef = (0, react.useRef)(null);
8446
+ (0, react.useEffect)(() => {
8447
+ const status = lesson?.status ?? null;
8448
+ const prev = prevStatusRef.current;
8449
+ prevStatusRef.current = status;
8450
+ if (lesson === null || lesson.kind !== "study" || status !== "mastered") {
8451
+ setBoundary(null);
8452
+ return;
8453
+ }
8454
+ if (prev === "mastered") return;
8455
+ if (boundaryDismissedLessons().has(lesson.lessonId)) return;
8456
+ const next = data !== null ? nextStudyAfterView(data.courses, lesson.lessonId) : null;
8457
+ setBoundary({
8458
+ lessonId: lesson.lessonId,
8459
+ nextId: next?.id ?? null,
8460
+ nextTitle: next?.title ?? null,
8461
+ last: next === null
8462
+ });
8463
+ }, [lesson, data]);
8464
+ const dismissBoundary = (0, react.useCallback)(() => {
8465
+ if (boundary !== null) try {
8466
+ const set = boundaryDismissedLessons();
8467
+ set.add(boundary.lessonId);
8468
+ localStorage.setItem(BOUNDARY_DISMISS_KEY, JSON.stringify([...set]));
8469
+ } catch {}
8470
+ setBoundary(null);
8471
+ }, [boundary]);
8472
+ const pointAtNext = (0, react.useCallback)((nextId) => {
8473
+ setNextHint(nextId);
8474
+ setNarrowPane("rail");
8475
+ }, []);
8144
8476
  const examLessonActive = lesson !== null && lesson.kind === "exam" && examOpen(data?.courses.flatMap((c) => c.sections).find((s) => s.lessons.some((l) => l.id === lesson.lessonId))?.lessons ?? []);
8145
8477
  const localBound = (0, react.useRef)(null);
8146
8478
  const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
@@ -8211,9 +8543,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8211
8543
  }
8212
8544
  const shell = PANEL_SHELL.current;
8213
8545
  if (shell !== null) shell.suppressHandBack(() => {
8214
- ctx.sessions.open(sessionId);
8546
+ stageSession(ctx.sessions, sessionId);
8215
8547
  });
8216
- else ctx.sessions.open(sessionId);
8548
+ else stageSession(ctx.sessions, sessionId);
8217
8549
  const actx = ctx.sessions.scope(sessionId);
8218
8550
  const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
8219
8551
  if (face === void 0) throw new Error("import session is not addressable yet");
@@ -8256,6 +8588,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8256
8588
  (0, react.useEffect)(() => {
8257
8589
  setPressure(null);
8258
8590
  if (boundId === null) return;
8591
+ const closeStage = acquireStageReference(ctx.sessions, boundId);
8259
8592
  let disposed = false;
8260
8593
  let off;
8261
8594
  try {
@@ -8281,22 +8614,27 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8281
8614
  }
8282
8615
  };
8283
8616
  read();
8284
- off = proj.subscribe(read);
8617
+ off = proj.subscribe(() => {
8618
+ queueMicrotask(read);
8619
+ });
8285
8620
  return () => {
8286
8621
  disposed = true;
8287
8622
  off?.();
8623
+ closeStage?.();
8288
8624
  };
8289
8625
  }
8290
8626
  } catch {}
8291
8627
  return () => {
8292
8628
  disposed = true;
8293
8629
  off?.();
8630
+ closeStage?.();
8294
8631
  };
8295
8632
  }, [boundId, ctx.sessions]);
8296
8633
  const [modelFace, setModelFace] = (0, react.useState)(null);
8297
8634
  (0, react.useEffect)(() => {
8298
8635
  setModelFace(null);
8299
8636
  if (boundId === null) return;
8637
+ const closeStage = acquireStageReference(ctx.sessions, boundId);
8300
8638
  let disposed = false;
8301
8639
  let off;
8302
8640
  try {
@@ -8308,16 +8646,20 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8308
8646
  if (!disposed && snap !== void 0 && typeof snap === "object" && snap.next !== void 0 && snap.next !== null) setModelFace(snap.next);
8309
8647
  };
8310
8648
  read();
8311
- off = proj.subscribe(read);
8649
+ off = proj.subscribe(() => {
8650
+ queueMicrotask(read);
8651
+ });
8312
8652
  return () => {
8313
8653
  disposed = true;
8314
8654
  off?.();
8655
+ closeStage?.();
8315
8656
  };
8316
8657
  }
8317
8658
  } catch {}
8318
8659
  return () => {
8319
8660
  disposed = true;
8320
8661
  off?.();
8662
+ closeStage?.();
8321
8663
  };
8322
8664
  }, [boundId, ctx.sessions]);
8323
8665
  (0, react.useEffect)(() => {
@@ -8346,7 +8688,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8346
8688
  };
8347
8689
  update();
8348
8690
  setFeedAttached(true);
8349
- offSource = source.subscribe(update);
8691
+ offSource = source.subscribe(() => {
8692
+ queueMicrotask(update);
8693
+ });
8350
8694
  return true;
8351
8695
  };
8352
8696
  const stage = () => {
@@ -8354,13 +8698,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8354
8698
  try {
8355
8699
  const shell = PANEL_SHELL.current;
8356
8700
  if (shell !== null) shell.suppressHandBack(() => {
8357
- ctx.sessions.open(boundId);
8701
+ stageSession(ctx.sessions, boundId);
8358
8702
  });
8359
- else ctx.sessions.open(boundId);
8703
+ else stageSession(ctx.sessions, boundId);
8360
8704
  } catch {}
8361
8705
  };
8362
8706
  const reconcile = () => {
8363
8707
  if (disposed || offSource !== void 0) return true;
8708
+ if (stagingMode(ctx.sessions) === "retain") {
8709
+ stage();
8710
+ return attach();
8711
+ }
8364
8712
  if (ctx.sessions.list?.getSnapshot().current !== boundId) {
8365
8713
  stage();
8366
8714
  return false;
@@ -8374,10 +8722,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8374
8722
  }, 150);
8375
8723
  const list = ctx.sessions.list;
8376
8724
  if (list !== void 0) offList = list.subscribe(() => {
8377
- if (disposed || reconcile()) {
8378
- offList?.();
8379
- offList = void 0;
8380
- }
8725
+ queueMicrotask(() => {
8726
+ if (disposed || reconcile()) {
8727
+ offList?.();
8728
+ offList = void 0;
8729
+ }
8730
+ });
8381
8731
  });
8382
8732
  return () => {
8383
8733
  disposed = true;
@@ -8442,9 +8792,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8442
8792
  }
8443
8793
  const shell = PANEL_SHELL.current;
8444
8794
  if (shell !== null) shell.suppressHandBack(() => {
8445
- ctx.sessions.open(sessionId);
8795
+ stageSession(ctx.sessions, sessionId);
8446
8796
  });
8447
- else ctx.sessions.open(sessionId);
8797
+ else stageSession(ctx.sessions, sessionId);
8448
8798
  const actx = ctx.sessions.scope(sessionId);
8449
8799
  const face = actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx);
8450
8800
  if (face === void 0) throw new Error("lesson session is not addressable yet");
@@ -8484,9 +8834,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8484
8834
  bindLessonSession(lesson.lessonId, sessionId);
8485
8835
  const shell = PANEL_SHELL.current;
8486
8836
  if (shell !== null) shell.suppressHandBack(() => {
8487
- ctx.sessions.open(sessionId);
8837
+ stageSession(ctx.sessions, sessionId);
8488
8838
  });
8489
- else ctx.sessions.open(sessionId);
8839
+ else stageSession(ctx.sessions, sessionId);
8490
8840
  };
8491
8841
  const startNewThread = () => {
8492
8842
  if (lesson === null) return;
@@ -8499,8 +8849,12 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8499
8849
  const renameLessonThreadUi = (sessionId, title) => {
8500
8850
  if (lesson === null) return;
8501
8851
  lessonThreadOp(lesson.lessonId, sessionId, "rename", { title }).then(() => {
8852
+ const closeRef = acquireStageReference(ctx.sessions, sessionId);
8853
+ if (closeRef === void 0) return;
8502
8854
  const actx = ctx.sessions.scope(sessionId);
8503
- (actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.rename?.(title).catch(() => {});
8855
+ (actx === void 0 ? void 0 : ctx.sessions.sessionOf(actx))?.rename?.(title).catch(() => {}).finally(() => {
8856
+ closeRef();
8857
+ });
8504
8858
  }, (err) => {
8505
8859
  setSendError(friendlyError(err, "action"));
8506
8860
  });
@@ -8542,7 +8896,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8542
8896
  }, (0, react.createElement)(CourseRail, {
8543
8897
  data,
8544
8898
  activate,
8545
- setFocus: guardedSetFocus,
8899
+ setFocus: (id) => {
8900
+ setNextHint(null);
8901
+ return guardedSetFocus(id);
8902
+ },
8546
8903
  searchLessons,
8547
8904
  deleteCourse,
8548
8905
  send,
@@ -8555,7 +8912,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8555
8912
  stop,
8556
8913
  sendImport,
8557
8914
  endImportSession,
8558
- paletteBus
8915
+ paletteBus,
8916
+ nextHintLessonId: nextHint
8559
8917
  }), (0, react.createElement)(PaneResizeHandle, {
8560
8918
  side: "rail",
8561
8919
  clampLive: railClampLive,
@@ -8629,7 +8987,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8629
8987
  },
8630
8988
  contextMeter,
8631
8989
  contextBreakdown: breakdown,
8632
- uploadAttachment
8990
+ uploadAttachment,
8991
+ boundary,
8992
+ onPointNext: pointAtNext,
8993
+ onDismissBoundary: dismissBoundary
8633
8994
  }), (0, react.createElement)(PaneResizeHandle, {
8634
8995
  side: "chat",
8635
8996
  clampLive: chatClampLive,
@@ -8966,7 +9327,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8966
9327
  PANEL_SHELL.current = shell;
8967
9328
  }
8968
9329
  /** 左栏:course picker, tree, review box, import. */
8969
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession }) {
9330
+ function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession, nextHintLessonId }) {
8970
9331
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
8971
9332
  const [query, setQuery] = (0, react.useState)("");
8972
9333
  const [searchRows, setSearchRows] = (0, react.useState)([]);
@@ -9194,6 +9555,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9194
9555
  return [(0, react.createElement)(ListSectionView, {
9195
9556
  key: section.title,
9196
9557
  streamingId: streamingLessonId,
9558
+ hintId: nextHintLessonId ?? null,
9197
9559
  section: {
9198
9560
  title: section.title,
9199
9561
  index: section.index,
@@ -9597,7 +9959,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9597
9959
  return cut <= 0 ? path : norm.slice(0, cut);
9598
9960
  }
9599
9961
  /** 中栏:the tutor chat stream with its own composer (upstream ChatStream + ChatComposer). */
9600
- function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment }) {
9962
+ function ChatPane({ data, lesson, rows, feedAttached, bound, busy, sendError, draft, setDraft, send, stop, setMode, narrowPane, onThreadSwitch, onThreadNew, onThreadRename, onThreadArchive, onThreadDelete, courseScope, onScopeToggle, contextMeter, contextBreakdown, uploadAttachment, boundary, onPointNext, onDismissBoundary }) {
9601
9963
  const stuck = (0, react.useRef)(true);
9602
9964
  const [showFab, setShowFab] = (0, react.useState)(false);
9603
9965
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
@@ -10104,7 +10466,23 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10104
10466
  onClick: () => {
10105
10467
  if (!dormant) send(s.message);
10106
10468
  }
10107
- }, s.label))) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
10469
+ }, s.label))) : null, boundary !== null ? (0, react.createElement)("div", {
10470
+ className: "lks14-boundary",
10471
+ "data-testid": "boundary-card"
10472
+ }, (0, react.createElement)(IconCrownFill16, { size: 16 }), (0, react.createElement)("div", { className: "lks14-boundary-text" }, (0, react.createElement)("div", { className: "lks14-boundary-title" }, boundary.last ? tr("boundary.course.done") : tr("boundary.done")), (0, react.createElement)("div", { className: "lks14-boundary-sub" }, boundary.last ? tr("boundary.last.sub") : boundary.nextTitle !== null ? courseScope === "course" ? tr("boundary.thread.sub", { title: boundary.nextTitle }) : tr("boundary.next.sub", { title: boundary.nextTitle }) : "")), !boundary.last && boundary.nextId !== null ? (0, react.createElement)("button", {
10473
+ className: "lks-btn primary",
10474
+ "data-testid": "boundary-next",
10475
+ onClick: () => {
10476
+ if (courseScope === "course" && !dormant) {
10477
+ send(tr("boundary.thread.send", { title: boundary.nextTitle ?? "" }));
10478
+ onDismissBoundary();
10479
+ } else onPointNext(boundary.nextId);
10480
+ }
10481
+ }, courseScope === "course" ? tr("boundary.thread.point") : tr("boundary.point")) : null, (0, react.createElement)("button", {
10482
+ className: "lks14-boundary-x",
10483
+ "aria-label": tr("boundary.dismiss"),
10484
+ onClick: onDismissBoundary
10485
+ }, "✕")) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
10108
10486
  className: "lks14-composer-card",
10109
10487
  onDragOver: (e) => {
10110
10488
  if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
@@ -10829,8 +11207,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10829
11207
  * event.
10830
11208
  *
10831
11209
  * Differences from stardeck's original: our own attribute namespace, a
10832
- * hand-back SUPPRESSION hook (the panel itself calls sessions.open to stage
10833
- * the focus lesson's thread — an internal navigation must not close the
11210
+ * hand-back SUPPRESSION hook (the panel itself stages the focus lesson's
11211
+ * thread through stage-session.ts — an internal navigation must not close the
10834
11212
  * panel; only USER session navigation hands the column back), and dsw-token
10835
11213
  * styling via the injected stylesheet.
10836
11214
  * @module dsh-plugin-lookatstudy/client/shell-entry
@@ -10989,13 +11367,21 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10989
11367
  doc.addEventListener(ACTIVATE_EVENT, onOtherActivate);
10990
11368
  let unsubscribeSessions;
10991
11369
  if (sessions?.list !== void 0) {
10992
- let lastCurrent = sessions.list.getSnapshot().current;
11370
+ const readSelection = () => {
11371
+ const snap = sessions.list.getSnapshot();
11372
+ if (snap.current !== void 0) return snap.current;
11373
+ return mainSelectionOf(snap.byId);
11374
+ };
11375
+ let lastCurrent = readSelection();
10993
11376
  unsubscribeSessions = sessions.list.subscribe(() => {
10994
- const next = sessions.list.getSnapshot().current;
10995
- if (next === lastCurrent) return;
10996
- const wasHydration = lastCurrent === void 0;
10997
- lastCurrent = next;
10998
- if (shouldHandBack(suppressed, wasHydration)) state.setOpen(false);
11377
+ const wasSuppressed = suppressed;
11378
+ queueMicrotask(() => {
11379
+ const next = readSelection();
11380
+ if (next === lastCurrent) return;
11381
+ const wasHydration = lastCurrent === void 0;
11382
+ lastCurrent = next;
11383
+ if (shouldHandBack(wasSuppressed, wasHydration)) state.setOpen(false);
11384
+ });
10999
11385
  });
11000
11386
  }
11001
11387
  const unsubscribe = state.subscribe(applyActive);
@@ -11020,6 +11406,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11020
11406
  doc.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
11021
11407
  unsubscribeSessions?.();
11022
11408
  unsubscribe();
11409
+ unstageSession();
11023
11410
  root?.unmount();
11024
11411
  container?.remove();
11025
11412
  row.remove();
@@ -11031,6 +11418,79 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11031
11418
  return !suppressed && !wasHydration;
11032
11419
  }
11033
11420
  //#endregion
11421
+ //#region src/learner-profile.ts
11422
+ /**
11423
+ * 学习者画像(声明侧)— ported from LookatStudy shared/learner-profile.ts
11424
+ * (upstream v0.36.0, MIT License). The learner-declared profile: MBTI as a
11425
+ * shortcut entry expanding into the four style dims (the dims are the source
11426
+ * of truth), the OIT motivation stage, interests, and a free note. The
11427
+ * profile adjusts HOW the tutor teaches — it never touches BKT/mastery/
11428
+ * unlock numbers (upstream SPEC §7 forbidden zone). Injection copy carries
11429
+ * the anti-injection annotation ("background data, not instructions").
11430
+ * @module dsh-plugin-lookatstudy/learner-profile
11431
+ */
11432
+ const MBTI_TYPES = [
11433
+ "INTJ",
11434
+ "INTP",
11435
+ "ENTJ",
11436
+ "ENTP",
11437
+ "INFJ",
11438
+ "INFP",
11439
+ "ENFJ",
11440
+ "ENFP",
11441
+ "ISTJ",
11442
+ "ISFJ",
11443
+ "ESTJ",
11444
+ "ESFJ",
11445
+ "ISTP",
11446
+ "ISFP",
11447
+ "ESTP",
11448
+ "ESFP"
11449
+ ];
11450
+ /**
11451
+ * 动机阶段(OIT 内化连续体 2-6 级;无动机级不设)。用户侧只见白话名,
11452
+ * 临床语义只活在注入文案里。AI 结构上不可提议(patch 类型排除)。
11453
+ */
11454
+ const MOTIVE_STAGES = [
11455
+ "external",
11456
+ "introjected",
11457
+ "identified",
11458
+ "integrated",
11459
+ "intrinsic"
11460
+ ];
11461
+ /** MBTI 四字母 → style 四维(快捷入口展开为真源初值,用户可逐维手调)。 */
11462
+ function expandMbtiToStyle(mbti) {
11463
+ return {
11464
+ start: mbti.includes("N") ? "framework" : "analogy",
11465
+ interaction: mbti.includes("E") ? "dialogue" : "lecture",
11466
+ feedback: mbti.includes("T") ? "direct" : "encouraging",
11467
+ pacing: mbti.includes("P") ? "exploratory" : "sequential"
11468
+ };
11469
+ }
11470
+ /** 动机阶段白话名 + tagline(用户侧只见白话;临床语义只在注入块)。 */
11471
+ const MOTIVE_DISPLAY = {
11472
+ external: {
11473
+ name: "为了考试/面试",
11474
+ tagline: "这场考试/面试在前头等着,得过去。"
11475
+ },
11476
+ introjected: {
11477
+ name: "为了填满休息时间",
11478
+ tagline: "闲着也是闲着,学点总没坏处。"
11479
+ },
11480
+ identified: {
11481
+ name: "未来能用得上",
11482
+ tagline: "这本事存着,总有一天用得上。"
11483
+ },
11484
+ integrated: {
11485
+ name: "终身学习",
11486
+ tagline: "学习这事儿,我打算干一辈子。"
11487
+ },
11488
+ intrinsic: {
11489
+ name: "享受学习过程",
11490
+ tagline: "懂的那个瞬间,本身就挺爽。"
11491
+ }
11492
+ };
11493
+ //#endregion
11034
11494
  //#region src/client/settings.tsx
11035
11495
  /**
11036
11496
  * The plugin's settings page: one `settings.section` entry riding the host's
@@ -11059,12 +11519,162 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11059
11519
  hintKey: "soul.practice.hint"
11060
11520
  }
11061
11521
  ];
11522
+ /** The four style dims (label key per value), upstream's DIM_LABELS UI face. */
11523
+ const STYLE_DIMS = [
11524
+ {
11525
+ key: "start",
11526
+ values: [["analogy", "settings.style.analogy"], ["framework", "settings.style.framework"]]
11527
+ },
11528
+ {
11529
+ key: "interaction",
11530
+ values: [["dialogue", "settings.style.dialogue"], ["lecture", "settings.style.lecture"]]
11531
+ },
11532
+ {
11533
+ key: "feedback",
11534
+ values: [["direct", "settings.style.direct"], ["encouraging", "settings.style.encouraging"]]
11535
+ },
11536
+ {
11537
+ key: "pacing",
11538
+ values: [["sequential", "settings.style.sequential"], ["exploratory", "settings.style.exploratory"]]
11539
+ }
11540
+ ];
11541
+ /**
11542
+ * The learner-profile editor + AI-suggestion consumer (upstream v0.36).
11543
+ * Form state seeds from the persisted profile; saving writes the human path
11544
+ * (which stales older pending suggestions via the updatedAt arbitration).
11545
+ */
11546
+ function ProfileSection({ data, save, resolve, fire }) {
11547
+ const profile = data?.profile ?? null;
11548
+ const [name, setName] = (0, react.useState)(profile?.name ?? "");
11549
+ const [mbti, setMbti] = (0, react.useState)(profile?.mbti ?? "");
11550
+ const [style, setStyle] = (0, react.useState)(() => ({
11551
+ start: profile?.style.start ?? null,
11552
+ interaction: profile?.style.interaction ?? null,
11553
+ feedback: profile?.style.feedback ?? null,
11554
+ pacing: profile?.style.pacing ?? null
11555
+ }));
11556
+ const [interests, setInterests] = (0, react.useState)(profile?.interests?.join("、") ?? "");
11557
+ const [motive, setMotive] = (0, react.useState)(profile?.motiveStage ?? "");
11558
+ const [freeNote, setFreeNote] = (0, react.useState)(profile?.freeNote ?? "");
11559
+ const seededFor = (0, react.useRef)(null);
11560
+ (0, react.useEffect)(() => {
11561
+ const stamp = profile?.updatedAt ?? null;
11562
+ if (stamp === null || stamp === seededFor.current) return;
11563
+ seededFor.current = stamp;
11564
+ setName(profile?.name ?? "");
11565
+ setMbti(profile?.mbti ?? "");
11566
+ setStyle({
11567
+ start: profile?.style.start ?? null,
11568
+ interaction: profile?.style.interaction ?? null,
11569
+ feedback: profile?.style.feedback ?? null,
11570
+ pacing: profile?.style.pacing ?? null
11571
+ });
11572
+ setInterests(profile?.interests?.join("、") ?? "");
11573
+ setMotive(profile?.motiveStage ?? "");
11574
+ setFreeNote(profile?.freeNote ?? "");
11575
+ }, [profile?.updatedAt]);
11576
+ const saveAll = () => {
11577
+ fire(save({
11578
+ name: name.trim() === "" ? null : name.trim(),
11579
+ mbti: mbti === "" ? null : mbti,
11580
+ interests,
11581
+ freeNote: freeNote.trim() === "" ? null : freeNote.trim(),
11582
+ motiveStage: motive === "" ? null : motive,
11583
+ style
11584
+ }));
11585
+ };
11586
+ const pickMbti = (next) => {
11587
+ setMbti(next);
11588
+ if (next !== "" && MBTI_TYPES.includes(next)) {
11589
+ const expanded = expandMbtiToStyle(next);
11590
+ setStyle({ ...expanded });
11591
+ }
11592
+ };
11593
+ const suggestions = data?.pendingProfileProposals ?? [];
11594
+ return (0, react.createElement)("section", {
11595
+ className: "lks-set-row",
11596
+ "data-testid": "profile-section"
11597
+ }, (0, react.createElement)("h3", null, tr("settings.profile")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.profile.hint")), (0, react.createElement)("div", { className: "lks-profile-grid" }, (0, react.createElement)("label", null, tr("settings.profile.name"), (0, react.createElement)("input", {
11598
+ className: "lks-set-input",
11599
+ value: name,
11600
+ "data-testid": "profile-name",
11601
+ onChange: (e) => {
11602
+ setName(e.target.value);
11603
+ }
11604
+ })), (0, react.createElement)("label", null, tr("settings.profile.mbti"), (0, react.createElement)("select", {
11605
+ className: "lks-set-select",
11606
+ value: mbti,
11607
+ "data-testid": "profile-mbti",
11608
+ onChange: (e) => {
11609
+ pickMbti(e.target.value);
11610
+ }
11611
+ }, (0, react.createElement)("option", { value: "" }, tr("settings.profile.unset")), ...MBTI_TYPES.map((t) => (0, react.createElement)("option", {
11612
+ key: t,
11613
+ value: t
11614
+ }, t)))), (0, react.createElement)("label", null, tr("settings.profile.interests"), (0, react.createElement)("input", {
11615
+ className: "lks-set-input",
11616
+ value: interests,
11617
+ "data-testid": "profile-interests",
11618
+ placeholder: tr("settings.profile.interests.ph"),
11619
+ onChange: (e) => {
11620
+ setInterests(e.target.value);
11621
+ }
11622
+ })), (0, react.createElement)("label", null, tr("settings.profile.note"), (0, react.createElement)("input", {
11623
+ className: "lks-set-input",
11624
+ value: freeNote,
11625
+ onChange: (e) => {
11626
+ setFreeNote(e.target.value);
11627
+ }
11628
+ })), ...STYLE_DIMS.map((dim) => (0, react.createElement)("label", { key: dim.key }, tr(`settings.style.${dim.key}`), (0, react.createElement)("span", { className: "lks-pills" }, ...dim.values.map(([value, labelKey]) => (0, react.createElement)("button", {
11629
+ key: value,
11630
+ className: `lks-pill${style[dim.key] === value ? " on" : ""}`,
11631
+ onClick: () => {
11632
+ setStyle((cur) => ({
11633
+ ...cur,
11634
+ [dim.key]: cur[dim.key] === value ? null : value
11635
+ }));
11636
+ }
11637
+ }, tr(labelKey)))))), (0, react.createElement)("label", null, tr("settings.profile.motive"), (0, react.createElement)("span", { className: "lks-pills" }, (0, react.createElement)("button", {
11638
+ className: `lks-pill${motive === "" ? " on" : ""}`,
11639
+ onClick: () => {
11640
+ setMotive("");
11641
+ }
11642
+ }, tr("settings.profile.unset")), ...MOTIVE_STAGES.map((stage) => (0, react.createElement)("button", {
11643
+ key: stage,
11644
+ className: `lks-pill${motive === stage ? " on" : ""}`,
11645
+ title: MOTIVE_DISPLAY[stage].tagline,
11646
+ onClick: () => {
11647
+ setMotive(stage);
11648
+ }
11649
+ }, MOTIVE_DISPLAY[stage].name))))), (0, react.createElement)("div", null, (0, react.createElement)("button", {
11650
+ className: "lks-btn primary",
11651
+ "data-testid": "profile-save",
11652
+ onClick: saveAll
11653
+ }, tr("settings.profile.save")), (0, react.createElement)("span", { className: "lks-set-state" }, tr("settings.profile.private"))), suggestions.length > 0 ? (0, react.createElement)("div", {
11654
+ className: "lks-profile-suggests",
11655
+ "data-testid": "profile-suggest-card"
11656
+ }, (0, react.createElement)("h4", null, tr("settings.suggest.head")), ...suggestions.map((sg) => (0, react.createElement)("div", {
11657
+ key: sg.id,
11658
+ className: "lks-profile-suggest"
11659
+ }, (0, react.createElement)("p", null, sg.rationale), (0, react.createElement)("div", { className: "lks-pills" }, (0, react.createElement)("button", {
11660
+ className: "lks-btn primary",
11661
+ "data-testid": "profile-suggest-apply",
11662
+ onClick: () => {
11663
+ fire(resolve(sg.id, true));
11664
+ }
11665
+ }, tr("settings.suggest.apply")), (0, react.createElement)("button", {
11666
+ className: "lks-btn ghost",
11667
+ onClick: () => {
11668
+ fire(resolve(sg.id, false));
11669
+ }
11670
+ }, tr("settings.suggest.ignore")))))) : null);
11671
+ }
11062
11672
  /**
11063
11673
  * The settings section component. Props from the settings shell (`close`) are
11064
11674
  * unused — every control stays on the page.
11065
11675
  */
11066
11676
  function StudySettingsSection() {
11067
- const { data, setMode, activate, setHistoryBudget } = useStudy();
11677
+ const { data, setMode, activate, setHistoryBudget, saveProfile, resolveProfileProposal } = useStudy();
11068
11678
  const [error, setError] = (0, react.useState)(null);
11069
11679
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
11070
11680
  const fire = (action) => {
@@ -11114,7 +11724,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11114
11724
  days: progress.streak,
11115
11725
  best: progress.longestStreak,
11116
11726
  freeze: progress.freezeCount
11117
- })))), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.about")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.about.hint")), data?.version ? (0, react.createElement)("a", {
11727
+ })), (0, react.createElement)("li", { "data-testid": "recap-mastered" }, tr("settings.stats.mastered", {
11728
+ count: (data?.courses ?? []).reduce((n, c) => n + c.mastered, 0),
11729
+ due: data?.dueCount ?? 0
11730
+ })))), (0, react.createElement)(ProfileSection, {
11731
+ data,
11732
+ save: saveProfile,
11733
+ resolve: resolveProfileProposal,
11734
+ fire
11735
+ }), (0, react.createElement)("section", { className: "lks-set-row" }, (0, react.createElement)("h3", null, tr("settings.about")), (0, react.createElement)("p", { className: "lks-set-hint" }, tr("settings.about.hint")), data?.version ? (0, react.createElement)("a", {
11118
11736
  href: `https://github.com/Kaiji-Z/dsh-plugin-lookatstudy/releases/tag/v${data.version}`,
11119
11737
  target: "_blank",
11120
11738
  rel: "noreferrer"