dsh-plugin-lookatstudy 0.25.4 → 0.26.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
@@ -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
@@ -1990,6 +2003,37 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
1990
2003
  });
1991
2004
  this.refresh();
1992
2005
  }
2006
+ /** Upstream v0.36: save the learner's OWN profile edit (the human path —
2007
+ * this is also what marks older AI suggestions stale). */
2008
+ async saveProfile(patch) {
2009
+ await fetchJson("/lookatstudy/api/profile", {
2010
+ method: "POST",
2011
+ headers: { "content-type": "application/json" },
2012
+ body: JSON.stringify(patch)
2013
+ });
2014
+ this.refresh();
2015
+ }
2016
+ /** Accept or ignore one AI profile suggestion (the settings-page consumer). */
2017
+ async resolveProfileProposal(id, accept) {
2018
+ await fetchJson("/lookatstudy/api/profile/resolve", {
2019
+ method: "POST",
2020
+ headers: { "content-type": "application/json" },
2021
+ body: JSON.stringify({
2022
+ id,
2023
+ accept
2024
+ })
2025
+ });
2026
+ this.refresh();
2027
+ }
2028
+ /** Anti-farming (upstream v0.35): a locally-judged practice card is human
2029
+ * grading — report it so the tutor-only mastery cap lifts on the lesson. */
2030
+ async reportPracticeGraded(lessonId) {
2031
+ await fetchJson("/lookatstudy/api/practice-graded", {
2032
+ method: "POST",
2033
+ headers: { "content-type": "application/json" },
2034
+ body: JSON.stringify({ lessonId })
2035
+ }).catch(() => void 0);
2036
+ }
1993
2037
  /** Delete one notebook entry from a lesson's Cornell zones. */
1994
2038
  async deleteNote(lessonId, noteId) {
1995
2039
  await fetchJson("/lookatstudy/api/note/delete", {
@@ -2159,6 +2203,9 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2159
2203
  setFocus: studyStore.setFocus.bind(studyStore),
2160
2204
  searchLessons: studyStore.searchLessons.bind(studyStore),
2161
2205
  tts: studyStore.tts.bind(studyStore),
2206
+ saveProfile: studyStore.saveProfile.bind(studyStore),
2207
+ resolveProfileProposal: studyStore.resolveProfileProposal.bind(studyStore),
2208
+ reportPracticeGraded: studyStore.reportPracticeGraded.bind(studyStore),
2162
2209
  setCourseScope: studyStore.setCourseScope.bind(studyStore),
2163
2210
  deleteCourse: studyStore.deleteCourse.bind(studyStore),
2164
2211
  deleteNote: studyStore.deleteNote.bind(studyStore),
@@ -2255,6 +2302,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2255
2302
  "proposal.banner": "《{lesson}》可以提前毕业了",
2256
2303
  "proposal.accept.msg": "我接受《{lesson}》的掌握度提议,帮我标记为掌握。",
2257
2304
  "proposal.decline.msg": "《{lesson}》我先再练练,暂不接受提前毕业。",
2305
+ "boundary.done": "本课完成,皇冠到手!",
2306
+ "boundary.course.done": "整门课程完成!",
2307
+ "boundary.next.sub": "下一课是《{title}》——在课程栏点它进入。",
2308
+ "boundary.thread.sub": "下一课是《{title}》——点开始,就在这条线上接着讲。",
2309
+ "boundary.thread.point": "开始下一课",
2310
+ "boundary.thread.send": "这一课完成了,我们开始下一课《{title}》吧。",
2311
+ "boundary.last.sub": "全部课时都已掌握,复习会按计划回来。",
2312
+ "boundary.point": "在课程栏指出下一课",
2313
+ "boundary.dismiss": "收起",
2258
2314
  "tutor.thinking": "导师思考中…",
2259
2315
  "header.xp": "经验值 {xp}",
2260
2316
  "header.streak": "连续学习天数",
@@ -2489,7 +2545,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2489
2545
  "settings.stats.courses": "课程 {count} 个",
2490
2546
  "settings.stats.xp": "XP {xp} · Lv{level}({pct}%)",
2491
2547
  "settings.stats.today": "今日 {xp}/{goal}",
2492
- "settings.stats.streak": "连续 {days} 天(最长 {best},剩冻结 {freeze})",
2548
+ "settings.stats.streak": "连续 {days} 天 · 最长 {best} 天 · 冻结 {freeze}",
2549
+ "settings.stats.mastered": "已掌握 {count} 课 · 待复习 {due} 课",
2550
+ "settings.profile": "学习者画像",
2551
+ "settings.profile.hint": "只属于你的背景事实,用来调整怎么教——不进任何评分。AI 的建议也在这里处理,不打断对话。",
2552
+ "settings.profile.name": "称呼",
2553
+ "settings.profile.mbti": "MBTI(快捷入口,自动展开为风格四维)",
2554
+ "settings.profile.interests": "兴趣点(顿号/逗号分隔,最多 8 个)",
2555
+ "settings.profile.interests.ph": "例如:天文、做菜、悬疑小说",
2556
+ "settings.profile.note": "想对导师说的话",
2557
+ "settings.profile.motive": "学习动机(只有你自己能设置)",
2558
+ "settings.profile.unset": "未设置",
2559
+ "settings.profile.save": "保存画像",
2560
+ "settings.profile.private": "画像只存本地 state.json,不会上传。",
2561
+ "settings.style.start": "讲解起点",
2562
+ "settings.style.analogy": "先具体实例建直觉",
2563
+ "settings.style.framework": "先框架后细节",
2564
+ "settings.style.interaction": "互动密度",
2565
+ "settings.style.dialogue": "边讲边问",
2566
+ "settings.style.lecture": "讲完一节再答疑",
2567
+ "settings.style.feedback": "反馈风格",
2568
+ "settings.style.direct": "直接纠错",
2569
+ "settings.style.encouraging": "先肯定再指正",
2570
+ "settings.style.pacing": "节奏",
2571
+ "settings.style.sequential": "按顺序推进",
2572
+ "settings.style.exploratory": "允许跳着学",
2573
+ "settings.suggest.head": "AI 建议的",
2574
+ "settings.suggest.apply": "采纳",
2575
+ "settings.suggest.ignore": "忽略",
2493
2576
  "settings.about": "关于",
2494
2577
  "settings.about.hint": "当前运行的构建版本,点击查看该版更新内容。",
2495
2578
  "settings.stateFile": "状态文件",
@@ -2626,6 +2709,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2626
2709
  "proposal.banner": "{lesson} is ready to graduate early",
2627
2710
  "proposal.accept.msg": "I accept the mastery proposal for \"{lesson}\" — mark it mastered.",
2628
2711
  "proposal.decline.msg": "I want more practice on \"{lesson}\" before graduating.",
2712
+ "boundary.done": "Lesson complete — crown earned!",
2713
+ "boundary.course.done": "Course complete!",
2714
+ "boundary.next.sub": "Next up: {title} — click it in the course rail to continue.",
2715
+ "boundary.thread.sub": "Next: {title} — continue right here in this thread.",
2716
+ "boundary.thread.point": "Start the next lesson",
2717
+ "boundary.thread.send": "This lesson is wrapped up — let's start the next one, {title}.",
2718
+ "boundary.last.sub": "Every lesson is mastered; reviews will come back on schedule.",
2719
+ "boundary.point": "Point at the next lesson",
2720
+ "boundary.dismiss": "Dismiss",
2629
2721
  "tutor.thinking": "The tutor is thinking…",
2630
2722
  "header.xp": "{xp} XP",
2631
2723
  "header.streak": "Day streak",
@@ -2860,7 +2952,34 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
2860
2952
  "settings.stats.courses": "{count} courses",
2861
2953
  "settings.stats.xp": "XP {xp} · Lv{level} ({pct}%)",
2862
2954
  "settings.stats.today": "Today {xp}/{goal}",
2863
- "settings.stats.streak": "Streak {days}d (best {best}, {freeze} freeze left)",
2955
+ "settings.stats.streak": "Streak {days} days · best {best} · freezes {freeze}",
2956
+ "settings.stats.mastered": "{count} lessons mastered · {due} due",
2957
+ "settings.profile": "Learner profile",
2958
+ "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.",
2959
+ "settings.profile.name": "Name",
2960
+ "settings.profile.mbti": "MBTI (shortcut — expands into the style dims)",
2961
+ "settings.profile.interests": "Interests (comma-separated, max 8)",
2962
+ "settings.profile.interests.ph": "e.g. astronomy, cooking, mystery novels",
2963
+ "settings.profile.note": "Note to the tutor",
2964
+ "settings.profile.motive": "Learning motive (only you can set this)",
2965
+ "settings.profile.unset": "Not set",
2966
+ "settings.profile.save": "Save profile",
2967
+ "settings.profile.private": "Stored in your local state.json only — never uploaded.",
2968
+ "settings.style.start": "Explanation start",
2969
+ "settings.style.analogy": "Concrete examples first",
2970
+ "settings.style.framework": "Framework first",
2971
+ "settings.style.interaction": "Interaction density",
2972
+ "settings.style.dialogue": "Questions as we go",
2973
+ "settings.style.lecture": "Lecture first, Q&A after",
2974
+ "settings.style.feedback": "Feedback style",
2975
+ "settings.style.direct": "Direct corrections",
2976
+ "settings.style.encouraging": "Encouraging corrections",
2977
+ "settings.style.pacing": "Pacing",
2978
+ "settings.style.sequential": "Sequential",
2979
+ "settings.style.exploratory": "Exploratory",
2980
+ "settings.suggest.head": "AI suggested",
2981
+ "settings.suggest.apply": "Accept",
2982
+ "settings.suggest.ignore": "Ignore",
2864
2983
  "settings.about": "About",
2865
2984
  "settings.about.hint": "The version of the running build — click to see its release notes.",
2866
2985
  "settings.stateFile": "State file",
@@ -4764,7 +4883,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4764
4883
  nodes
4765
4884
  };
4766
4885
  }
4767
- function offsetsToRange(model, start, end) {
4886
+ function offsetsToRange$1(model, start, end) {
4768
4887
  const range = document.createRange();
4769
4888
  let setStart = false;
4770
4889
  let setEnd = false;
@@ -4823,7 +4942,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
4823
4942
  if (m === null && from > 0) m = matchSentenceAligned(model.text, trimmed, 0);
4824
4943
  if (m === null) return null;
4825
4944
  readingCursors.set(container, m[1]);
4826
- const range = offsetsToRange(model, m[0], m[1]);
4945
+ const range = offsetsToRange$1(model, m[0], m[1]);
4827
4946
  if (range === null) return null;
4828
4947
  const cssLike = CSS;
4829
4948
  if (typeof Highlight !== "undefined" && cssLike.highlights !== void 0) try {
@@ -5249,6 +5368,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5249
5368
  };
5250
5369
  }
5251
5370
  /**
5371
+ * Tool-error visibility (upstream v0.37.1 toolErrorVisibility, part-accumulator):
5372
+ * an error tool row that is SUPERSEDED by a later same-tool call within the
5373
+ * same turn (no learner message in between) is dropped — weak models fail a
5374
+ * zod-validated input on the first try and succeed on the retry (observed
5375
+ * live: the quiz error block flashed, then the quiz card landed), and the
5376
+ * stale transient failure is pure noise. A real failure — no successor —
5377
+ * stays visible; nothing is silently swallowed. Pure.
5378
+ */
5379
+ function hideSupersededToolErrors(rows) {
5380
+ const out = [];
5381
+ for (let i = 0; i < rows.length; i++) {
5382
+ const row = rows[i];
5383
+ if (row.role === "tool" && row.toolState === "error") {
5384
+ let superseded = false;
5385
+ for (let j = i + 1; j < rows.length; j++) {
5386
+ const later = rows[j];
5387
+ if (later.role === "user") break;
5388
+ if (later.role === "tool" && later.text === row.text && (later.toolState === "loading" || later.toolState === "done")) {
5389
+ superseded = true;
5390
+ break;
5391
+ }
5392
+ }
5393
+ if (superseded) continue;
5394
+ }
5395
+ out.push(row);
5396
+ }
5397
+ return out;
5398
+ }
5399
+ /**
5252
5400
  * Fold one event-window snapshot into ordered chat rows. The transient
5253
5401
  * `text-delta` chunks of the most recent attempt accumulate into a single
5254
5402
  * streaming row (the durable `assistant/message` replaces it on settlement).
@@ -5369,7 +5517,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
5369
5517
  running: true
5370
5518
  });
5371
5519
  }
5372
- return rows;
5520
+ return hideSupersededToolErrors(rows);
5373
5521
  }
5374
5522
  /**
5375
5523
  * Parse an ask_user_question call's arguments into the answerable row payload
@@ -6058,6 +6206,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6058
6206
  if (!showScore || !allAnswered || hookSent) return;
6059
6207
  setHookSent(true);
6060
6208
  onFinished?.(score.correct === score.total);
6209
+ studyStore.reportPracticeGraded(lessonId);
6061
6210
  const next = {
6062
6211
  answers: [...progress.answers],
6063
6212
  done: true
@@ -6924,11 +7073,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6924
7073
  nodes
6925
7074
  };
6926
7075
  }
6927
- /** The mark class applied to persisted highlights. */
7076
+ /** The mark class applied to persisted highlights (DOM fallback channel only). */
6928
7077
  const HIGHLIGHT_MARK = "lks-hl";
6929
7078
  /**
7079
+ * Persistent-note highlight registry name — the CSS Custom Highlight API
7080
+ * channel (upstream v0.35.1 root fix): the old scheme split text nodes and
7081
+ * inserted <mark>, rewriting DOM a framework may reconcile over — any later
7082
+ * re-render could throw a DOMException into the error boundary. The registry
7083
+ * channel registers Ranges in CSS.highlights and touches ZERO DOM nodes.
7084
+ * Styled by the `::highlight(lks-note-hl)` rule in the upstream skin.
7085
+ */
7086
+ const NOTE_HIGHLIGHT_NAME = "lks-note-hl";
7087
+ /** Whether the registration channel exists (Highlight ctor + CSS.highlights). */
7088
+ function supportsHighlightMarks() {
7089
+ return typeof Highlight !== "undefined" && !!CSS.highlights;
7090
+ }
7091
+ /** Live note ranges: quote → Range. CSS.highlights is a document-level
7092
+ * singleton, so every container shares one Highlight; dead ranges (nodes
7093
+ * detached by a re-render of the source) are pruned on rebuild so the map
7094
+ * cannot grow without bound. */
7095
+ const liveNoteRanges = /* @__PURE__ */ new Map();
7096
+ const noteRangeOwners = /* @__PURE__ */ new WeakMap();
7097
+ function rebuildNoteHighlight() {
7098
+ const cssLike = CSS;
7099
+ for (const [quote, r] of liveNoteRanges) if (!r.startContainer.isConnected) liveNoteRanges.delete(quote);
7100
+ try {
7101
+ if (liveNoteRanges.size === 0) cssLike.highlights?.delete(NOTE_HIGHLIGHT_NAME);
7102
+ else cssLike.highlights?.set(NOTE_HIGHLIGHT_NAME, new Highlight(...liveNoteRanges.values()));
7103
+ } catch {}
7104
+ }
7105
+ /**
6930
7106
  * Apply one highlight range to the live DOM: split the boundary text nodes
6931
7107
  * and wrap the covering segments in marks. Returns the created marks.
7108
+ * (The DOM fallback channel — only reached when the Highlight API is absent.)
6932
7109
  */
6933
7110
  function wrapRange(model, start, end) {
6934
7111
  const marks = [];
@@ -6949,12 +7126,35 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6949
7126
  }
6950
7127
  /**
6951
7128
  * 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.
7129
+ * quote anchors a highlight. Registry channel first (zero DOM mutation — a
7130
+ * framework re-render can never hit a foreign node); the mark-wrapping
7131
+ * fallback only runs on engines without CSS.highlights. Idempotent per call —
7132
+ * call on a freshly rendered container; re-entry replaces this container's
7133
+ * registered ranges first.
7134
+ * @returns how many notes got at least one highlight.
6955
7135
  */
6956
7136
  function applyHighlights(container, quotes) {
6957
7137
  let applied = 0;
7138
+ if (supportsHighlightMarks()) {
7139
+ const owned = noteRangeOwners.get(container);
7140
+ if (owned !== void 0) for (const quote of owned) liveNoteRanges.delete(quote);
7141
+ const hits = /* @__PURE__ */ new Map();
7142
+ for (const quote of quotes) {
7143
+ const trimmed = quote.trim();
7144
+ if (trimmed.length < 2) continue;
7145
+ const model = getTextModel(container);
7146
+ const located = locateInModel(model, trimmed, void 0);
7147
+ if (located === null) continue;
7148
+ const range = offsetsToRange(model, located.start, located.end);
7149
+ if (range === null) continue;
7150
+ hits.set(trimmed, range);
7151
+ liveNoteRanges.set(trimmed, range);
7152
+ applied += 1;
7153
+ }
7154
+ noteRangeOwners.set(container, new Set(hits.keys()));
7155
+ rebuildNoteHighlight();
7156
+ return applied;
7157
+ }
6958
7158
  for (const quote of quotes) {
6959
7159
  const trimmed = quote.trim();
6960
7160
  if (trimmed.length < 2) continue;
@@ -6965,6 +7165,22 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6965
7165
  }
6966
7166
  return applied;
6967
7167
  }
7168
+ /** Build one Range across the model's text nodes (null when the browser
7169
+ * refuses — e.g. a detached node between model build and call). */
7170
+ function offsetsToRange(model, start, end) {
7171
+ const segments = planSegments(model.nodes, start, end);
7172
+ if (segments.length === 0) return null;
7173
+ const first = segments[0];
7174
+ const last = segments[segments.length - 1];
7175
+ try {
7176
+ const range = new Range();
7177
+ range.setStart(model.nodes[first.index].node, first.localStart);
7178
+ range.setEnd(model.nodes[last.index].node, last.localEnd);
7179
+ return range;
7180
+ } catch {
7181
+ return null;
7182
+ }
7183
+ }
6968
7184
  //#endregion
6969
7185
  //#region src/client/maprail.tsx
6970
7186
  /**
@@ -6995,17 +7211,31 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
6995
7211
  if (lesson.status === "in_progress") return lesson.masteryPct ?? 0;
6996
7212
  return null;
6997
7213
  }
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
- */
7214
+ function nextStudyAfterView(courses, lessonId) {
7215
+ for (const course of courses) for (let si = 0; si < course.sections.length; si++) {
7216
+ const lessons = course.sections[si].lessons;
7217
+ const li = lessons.findIndex((l) => l.id === lessonId);
7218
+ if (li < 0) continue;
7219
+ for (let j = li + 1; j < lessons.length; j++) if (lessons[j].kind === "study") return {
7220
+ id: lessons[j].id,
7221
+ title: lessons[j].title
7222
+ };
7223
+ for (let s = si + 1; s < course.sections.length; s++) {
7224
+ const first = course.sections[s].lessons.find((l) => l.kind === "study");
7225
+ if (first !== void 0) return {
7226
+ id: first.id,
7227
+ title: first.title
7228
+ };
7229
+ }
7230
+ return null;
7231
+ }
7232
+ return null;
7233
+ }
7004
7234
  function sectionWorldOf(section) {
7005
7235
  return section.lessons.length > 0 && section.lessons.every((l) => l.kind === "practice") ? "practice" : "study";
7006
7236
  }
7007
7237
  /** One section: a quiet header toggle + the lesson rows. */
7008
- function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId }) {
7238
+ function ListSectionView({ section, examAllowed, open, onToggle, onJump, streamingId, hintId }) {
7009
7239
  const lessons = section.lessons;
7010
7240
  const done = lessons.filter((l) => l.status === "mastered").length;
7011
7241
  return (0, react.createElement)("section", { className: "lks-railsec" }, (0, react.createElement)("button", {
@@ -7022,7 +7252,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7022
7252
  return (0, react.createElement)("button", {
7023
7253
  key: lesson.id,
7024
7254
  type: "button",
7025
- className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}`,
7255
+ className: `lks-lessorow st-${state}${lesson.focus ? " selected" : ""}${hintId !== void 0 && hintId !== null && hintId === lesson.id ? " next-hint" : ""}`,
7026
7256
  "data-node-id": lesson.id,
7027
7257
  "aria-disabled": locked || void 0,
7028
7258
  "data-tooltip": `${lesson.title} — ${stateWord}`,
@@ -7864,6 +8094,17 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
7864
8094
  }
7865
8095
  }
7866
8096
  /** C8: the most recent decided proposal's badge payload (render order preserved). */
8097
+ /** Upstream v0.37 boundary card: lessons whose card the learner dismissed
8098
+ * (persisted so a remount does not re-nag about an already-mastered lesson). */
8099
+ const BOUNDARY_DISMISS_KEY = "lks-boundary-dismissed";
8100
+ function boundaryDismissedLessons() {
8101
+ try {
8102
+ const raw = JSON.parse(localStorage.getItem(BOUNDARY_DISMISS_KEY) ?? "[]");
8103
+ return new Set(Array.isArray(raw) ? raw.filter((s) => typeof s === "string") : []);
8104
+ } catch {
8105
+ return /* @__PURE__ */ new Set();
8106
+ }
8107
+ }
7867
8108
  function decidedBadgeOf(decided) {
7868
8109
  const entries = Object.values(decided);
7869
8110
  return entries.length > 0 ? entries[entries.length - 1] : null;
@@ -8141,6 +8382,39 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8141
8382
  const [draft, setDraft] = (0, react.useState)("");
8142
8383
  const [narrowPane, setNarrowPane] = (0, react.useState)(pickNarrowPane(null));
8143
8384
  const lesson = data?.lesson ?? null;
8385
+ const [boundary, setBoundary] = (0, react.useState)(null);
8386
+ const [nextHint, setNextHint] = (0, react.useState)(null);
8387
+ const prevStatusRef = (0, react.useRef)(null);
8388
+ (0, react.useEffect)(() => {
8389
+ const status = lesson?.status ?? null;
8390
+ const prev = prevStatusRef.current;
8391
+ prevStatusRef.current = status;
8392
+ if (lesson === null || lesson.kind !== "study" || status !== "mastered") {
8393
+ setBoundary(null);
8394
+ return;
8395
+ }
8396
+ if (prev === "mastered") return;
8397
+ if (boundaryDismissedLessons().has(lesson.lessonId)) return;
8398
+ const next = data !== null ? nextStudyAfterView(data.courses, lesson.lessonId) : null;
8399
+ setBoundary({
8400
+ lessonId: lesson.lessonId,
8401
+ nextId: next?.id ?? null,
8402
+ nextTitle: next?.title ?? null,
8403
+ last: next === null
8404
+ });
8405
+ }, [lesson, data]);
8406
+ const dismissBoundary = (0, react.useCallback)(() => {
8407
+ if (boundary !== null) try {
8408
+ const set = boundaryDismissedLessons();
8409
+ set.add(boundary.lessonId);
8410
+ localStorage.setItem(BOUNDARY_DISMISS_KEY, JSON.stringify([...set]));
8411
+ } catch {}
8412
+ setBoundary(null);
8413
+ }, [boundary]);
8414
+ const pointAtNext = (0, react.useCallback)((nextId) => {
8415
+ setNextHint(nextId);
8416
+ setNarrowPane("rail");
8417
+ }, []);
8144
8418
  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
8419
  const localBound = (0, react.useRef)(null);
8146
8420
  const boundSessionOf = (lessonId) => lessonId !== null && localBound.current?.lessonId === lessonId ? localBound.current.sessionId : null;
@@ -8542,7 +8816,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8542
8816
  }, (0, react.createElement)(CourseRail, {
8543
8817
  data,
8544
8818
  activate,
8545
- setFocus: guardedSetFocus,
8819
+ setFocus: (id) => {
8820
+ setNextHint(null);
8821
+ return guardedSetFocus(id);
8822
+ },
8546
8823
  searchLessons,
8547
8824
  deleteCourse,
8548
8825
  send,
@@ -8555,7 +8832,8 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8555
8832
  stop,
8556
8833
  sendImport,
8557
8834
  endImportSession,
8558
- paletteBus
8835
+ paletteBus,
8836
+ nextHintLessonId: nextHint
8559
8837
  }), (0, react.createElement)(PaneResizeHandle, {
8560
8838
  side: "rail",
8561
8839
  clampLive: railClampLive,
@@ -8629,7 +8907,10 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8629
8907
  },
8630
8908
  contextMeter,
8631
8909
  contextBreakdown: breakdown,
8632
- uploadAttachment
8910
+ uploadAttachment,
8911
+ boundary,
8912
+ onPointNext: pointAtNext,
8913
+ onDismissBoundary: dismissBoundary
8633
8914
  }), (0, react.createElement)(PaneResizeHandle, {
8634
8915
  side: "chat",
8635
8916
  clampLive: chatClampLive,
@@ -8966,7 +9247,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
8966
9247
  PANEL_SHELL.current = shell;
8967
9248
  }
8968
9249
  /** 左栏:course picker, tree, review box, import. */
8969
- function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession }) {
9250
+ function CourseRail({ data, activate, setFocus, searchLessons, deleteCourse, send, onJumped, streamingLessonId, turnActive, importProgress, stop, paletteBus, sendImport, endImportSession, nextHintLessonId }) {
8970
9251
  const [selectedCourse, setSelectedCourse] = (0, react.useState)("");
8971
9252
  const [query, setQuery] = (0, react.useState)("");
8972
9253
  const [searchRows, setSearchRows] = (0, react.useState)([]);
@@ -9194,6 +9475,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9194
9475
  return [(0, react.createElement)(ListSectionView, {
9195
9476
  key: section.title,
9196
9477
  streamingId: streamingLessonId,
9478
+ hintId: nextHintLessonId ?? null,
9197
9479
  section: {
9198
9480
  title: section.title,
9199
9481
  index: section.index,
@@ -9597,7 +9879,7 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
9597
9879
  return cut <= 0 ? path : norm.slice(0, cut);
9598
9880
  }
9599
9881
  /** 中栏: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 }) {
9882
+ 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
9883
  const stuck = (0, react.useRef)(true);
9602
9884
  const [showFab, setShowFab] = (0, react.useState)(false);
9603
9885
  const [threadsOpen, setThreadsOpen] = (0, react.useState)(false);
@@ -10104,7 +10386,23 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
10104
10386
  onClick: () => {
10105
10387
  if (!dormant) send(s.message);
10106
10388
  }
10107
- }, s.label))) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
10389
+ }, s.label))) : null, boundary !== null ? (0, react.createElement)("div", {
10390
+ className: "lks14-boundary",
10391
+ "data-testid": "boundary-card"
10392
+ }, (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", {
10393
+ className: "lks-btn primary",
10394
+ "data-testid": "boundary-next",
10395
+ onClick: () => {
10396
+ if (courseScope === "course" && !dormant) {
10397
+ send(tr("boundary.thread.send", { title: boundary.nextTitle ?? "" }));
10398
+ onDismissBoundary();
10399
+ } else onPointNext(boundary.nextId);
10400
+ }
10401
+ }, courseScope === "course" ? tr("boundary.thread.point") : tr("boundary.point")) : null, (0, react.createElement)("button", {
10402
+ className: "lks14-boundary-x",
10403
+ "aria-label": tr("boundary.dismiss"),
10404
+ onClick: onDismissBoundary
10405
+ }, "✕")) : null, (0, react.createElement)("div", { className: "lks14-composer" }, (0, react.createElement)("div", {
10108
10406
  className: "lks14-composer-card",
10109
10407
  onDragOver: (e) => {
10110
10408
  if (e.dataTransfer?.types.includes("Files") === true) e.preventDefault();
@@ -11031,6 +11329,79 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11031
11329
  return !suppressed && !wasHydration;
11032
11330
  }
11033
11331
  //#endregion
11332
+ //#region src/learner-profile.ts
11333
+ /**
11334
+ * 学习者画像(声明侧)— ported from LookatStudy shared/learner-profile.ts
11335
+ * (upstream v0.36.0, MIT License). The learner-declared profile: MBTI as a
11336
+ * shortcut entry expanding into the four style dims (the dims are the source
11337
+ * of truth), the OIT motivation stage, interests, and a free note. The
11338
+ * profile adjusts HOW the tutor teaches — it never touches BKT/mastery/
11339
+ * unlock numbers (upstream SPEC §7 forbidden zone). Injection copy carries
11340
+ * the anti-injection annotation ("background data, not instructions").
11341
+ * @module dsh-plugin-lookatstudy/learner-profile
11342
+ */
11343
+ const MBTI_TYPES = [
11344
+ "INTJ",
11345
+ "INTP",
11346
+ "ENTJ",
11347
+ "ENTP",
11348
+ "INFJ",
11349
+ "INFP",
11350
+ "ENFJ",
11351
+ "ENFP",
11352
+ "ISTJ",
11353
+ "ISFJ",
11354
+ "ESTJ",
11355
+ "ESFJ",
11356
+ "ISTP",
11357
+ "ISFP",
11358
+ "ESTP",
11359
+ "ESFP"
11360
+ ];
11361
+ /**
11362
+ * 动机阶段(OIT 内化连续体 2-6 级;无动机级不设)。用户侧只见白话名,
11363
+ * 临床语义只活在注入文案里。AI 结构上不可提议(patch 类型排除)。
11364
+ */
11365
+ const MOTIVE_STAGES = [
11366
+ "external",
11367
+ "introjected",
11368
+ "identified",
11369
+ "integrated",
11370
+ "intrinsic"
11371
+ ];
11372
+ /** MBTI 四字母 → style 四维(快捷入口展开为真源初值,用户可逐维手调)。 */
11373
+ function expandMbtiToStyle(mbti) {
11374
+ return {
11375
+ start: mbti.includes("N") ? "framework" : "analogy",
11376
+ interaction: mbti.includes("E") ? "dialogue" : "lecture",
11377
+ feedback: mbti.includes("T") ? "direct" : "encouraging",
11378
+ pacing: mbti.includes("P") ? "exploratory" : "sequential"
11379
+ };
11380
+ }
11381
+ /** 动机阶段白话名 + tagline(用户侧只见白话;临床语义只在注入块)。 */
11382
+ const MOTIVE_DISPLAY = {
11383
+ external: {
11384
+ name: "为了考试/面试",
11385
+ tagline: "这场考试/面试在前头等着,得过去。"
11386
+ },
11387
+ introjected: {
11388
+ name: "为了填满休息时间",
11389
+ tagline: "闲着也是闲着,学点总没坏处。"
11390
+ },
11391
+ identified: {
11392
+ name: "未来能用得上",
11393
+ tagline: "这本事存着,总有一天用得上。"
11394
+ },
11395
+ integrated: {
11396
+ name: "终身学习",
11397
+ tagline: "学习这事儿,我打算干一辈子。"
11398
+ },
11399
+ intrinsic: {
11400
+ name: "享受学习过程",
11401
+ tagline: "懂的那个瞬间,本身就挺爽。"
11402
+ }
11403
+ };
11404
+ //#endregion
11034
11405
  //#region src/client/settings.tsx
11035
11406
  /**
11036
11407
  * The plugin's settings page: one `settings.section` entry riding the host's
@@ -11059,12 +11430,162 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11059
11430
  hintKey: "soul.practice.hint"
11060
11431
  }
11061
11432
  ];
11433
+ /** The four style dims (label key per value), upstream's DIM_LABELS UI face. */
11434
+ const STYLE_DIMS = [
11435
+ {
11436
+ key: "start",
11437
+ values: [["analogy", "settings.style.analogy"], ["framework", "settings.style.framework"]]
11438
+ },
11439
+ {
11440
+ key: "interaction",
11441
+ values: [["dialogue", "settings.style.dialogue"], ["lecture", "settings.style.lecture"]]
11442
+ },
11443
+ {
11444
+ key: "feedback",
11445
+ values: [["direct", "settings.style.direct"], ["encouraging", "settings.style.encouraging"]]
11446
+ },
11447
+ {
11448
+ key: "pacing",
11449
+ values: [["sequential", "settings.style.sequential"], ["exploratory", "settings.style.exploratory"]]
11450
+ }
11451
+ ];
11452
+ /**
11453
+ * The learner-profile editor + AI-suggestion consumer (upstream v0.36).
11454
+ * Form state seeds from the persisted profile; saving writes the human path
11455
+ * (which stales older pending suggestions via the updatedAt arbitration).
11456
+ */
11457
+ function ProfileSection({ data, save, resolve, fire }) {
11458
+ const profile = data?.profile ?? null;
11459
+ const [name, setName] = (0, react.useState)(profile?.name ?? "");
11460
+ const [mbti, setMbti] = (0, react.useState)(profile?.mbti ?? "");
11461
+ const [style, setStyle] = (0, react.useState)(() => ({
11462
+ start: profile?.style.start ?? null,
11463
+ interaction: profile?.style.interaction ?? null,
11464
+ feedback: profile?.style.feedback ?? null,
11465
+ pacing: profile?.style.pacing ?? null
11466
+ }));
11467
+ const [interests, setInterests] = (0, react.useState)(profile?.interests?.join("、") ?? "");
11468
+ const [motive, setMotive] = (0, react.useState)(profile?.motiveStage ?? "");
11469
+ const [freeNote, setFreeNote] = (0, react.useState)(profile?.freeNote ?? "");
11470
+ const seededFor = (0, react.useRef)(null);
11471
+ (0, react.useEffect)(() => {
11472
+ const stamp = profile?.updatedAt ?? null;
11473
+ if (stamp === null || stamp === seededFor.current) return;
11474
+ seededFor.current = stamp;
11475
+ setName(profile?.name ?? "");
11476
+ setMbti(profile?.mbti ?? "");
11477
+ setStyle({
11478
+ start: profile?.style.start ?? null,
11479
+ interaction: profile?.style.interaction ?? null,
11480
+ feedback: profile?.style.feedback ?? null,
11481
+ pacing: profile?.style.pacing ?? null
11482
+ });
11483
+ setInterests(profile?.interests?.join("、") ?? "");
11484
+ setMotive(profile?.motiveStage ?? "");
11485
+ setFreeNote(profile?.freeNote ?? "");
11486
+ }, [profile?.updatedAt]);
11487
+ const saveAll = () => {
11488
+ fire(save({
11489
+ name: name.trim() === "" ? null : name.trim(),
11490
+ mbti: mbti === "" ? null : mbti,
11491
+ interests,
11492
+ freeNote: freeNote.trim() === "" ? null : freeNote.trim(),
11493
+ motiveStage: motive === "" ? null : motive,
11494
+ style
11495
+ }));
11496
+ };
11497
+ const pickMbti = (next) => {
11498
+ setMbti(next);
11499
+ if (next !== "" && MBTI_TYPES.includes(next)) {
11500
+ const expanded = expandMbtiToStyle(next);
11501
+ setStyle({ ...expanded });
11502
+ }
11503
+ };
11504
+ const suggestions = data?.pendingProfileProposals ?? [];
11505
+ return (0, react.createElement)("section", {
11506
+ className: "lks-set-row",
11507
+ "data-testid": "profile-section"
11508
+ }, (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", {
11509
+ className: "lks-set-input",
11510
+ value: name,
11511
+ "data-testid": "profile-name",
11512
+ onChange: (e) => {
11513
+ setName(e.target.value);
11514
+ }
11515
+ })), (0, react.createElement)("label", null, tr("settings.profile.mbti"), (0, react.createElement)("select", {
11516
+ className: "lks-set-select",
11517
+ value: mbti,
11518
+ "data-testid": "profile-mbti",
11519
+ onChange: (e) => {
11520
+ pickMbti(e.target.value);
11521
+ }
11522
+ }, (0, react.createElement)("option", { value: "" }, tr("settings.profile.unset")), ...MBTI_TYPES.map((t) => (0, react.createElement)("option", {
11523
+ key: t,
11524
+ value: t
11525
+ }, t)))), (0, react.createElement)("label", null, tr("settings.profile.interests"), (0, react.createElement)("input", {
11526
+ className: "lks-set-input",
11527
+ value: interests,
11528
+ "data-testid": "profile-interests",
11529
+ placeholder: tr("settings.profile.interests.ph"),
11530
+ onChange: (e) => {
11531
+ setInterests(e.target.value);
11532
+ }
11533
+ })), (0, react.createElement)("label", null, tr("settings.profile.note"), (0, react.createElement)("input", {
11534
+ className: "lks-set-input",
11535
+ value: freeNote,
11536
+ onChange: (e) => {
11537
+ setFreeNote(e.target.value);
11538
+ }
11539
+ })), ...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", {
11540
+ key: value,
11541
+ className: `lks-pill${style[dim.key] === value ? " on" : ""}`,
11542
+ onClick: () => {
11543
+ setStyle((cur) => ({
11544
+ ...cur,
11545
+ [dim.key]: cur[dim.key] === value ? null : value
11546
+ }));
11547
+ }
11548
+ }, tr(labelKey)))))), (0, react.createElement)("label", null, tr("settings.profile.motive"), (0, react.createElement)("span", { className: "lks-pills" }, (0, react.createElement)("button", {
11549
+ className: `lks-pill${motive === "" ? " on" : ""}`,
11550
+ onClick: () => {
11551
+ setMotive("");
11552
+ }
11553
+ }, tr("settings.profile.unset")), ...MOTIVE_STAGES.map((stage) => (0, react.createElement)("button", {
11554
+ key: stage,
11555
+ className: `lks-pill${motive === stage ? " on" : ""}`,
11556
+ title: MOTIVE_DISPLAY[stage].tagline,
11557
+ onClick: () => {
11558
+ setMotive(stage);
11559
+ }
11560
+ }, MOTIVE_DISPLAY[stage].name))))), (0, react.createElement)("div", null, (0, react.createElement)("button", {
11561
+ className: "lks-btn primary",
11562
+ "data-testid": "profile-save",
11563
+ onClick: saveAll
11564
+ }, tr("settings.profile.save")), (0, react.createElement)("span", { className: "lks-set-state" }, tr("settings.profile.private"))), suggestions.length > 0 ? (0, react.createElement)("div", {
11565
+ className: "lks-profile-suggests",
11566
+ "data-testid": "profile-suggest-card"
11567
+ }, (0, react.createElement)("h4", null, tr("settings.suggest.head")), ...suggestions.map((sg) => (0, react.createElement)("div", {
11568
+ key: sg.id,
11569
+ className: "lks-profile-suggest"
11570
+ }, (0, react.createElement)("p", null, sg.rationale), (0, react.createElement)("div", { className: "lks-pills" }, (0, react.createElement)("button", {
11571
+ className: "lks-btn primary",
11572
+ "data-testid": "profile-suggest-apply",
11573
+ onClick: () => {
11574
+ fire(resolve(sg.id, true));
11575
+ }
11576
+ }, tr("settings.suggest.apply")), (0, react.createElement)("button", {
11577
+ className: "lks-btn ghost",
11578
+ onClick: () => {
11579
+ fire(resolve(sg.id, false));
11580
+ }
11581
+ }, tr("settings.suggest.ignore")))))) : null);
11582
+ }
11062
11583
  /**
11063
11584
  * The settings section component. Props from the settings shell (`close`) are
11064
11585
  * unused — every control stays on the page.
11065
11586
  */
11066
11587
  function StudySettingsSection() {
11067
- const { data, setMode, activate, setHistoryBudget } = useStudy();
11588
+ const { data, setMode, activate, setHistoryBudget, saveProfile, resolveProfileProposal } = useStudy();
11068
11589
  const [error, setError] = (0, react.useState)(null);
11069
11590
  const [voice, setVoice] = (0, react.useState)(storedTtsVoice);
11070
11591
  const fire = (action) => {
@@ -11114,7 +11635,15 @@ mark.lks-hl{background:var(--dsw-alias-state-warn-tertiary);color:inherit;border
11114
11635
  days: progress.streak,
11115
11636
  best: progress.longestStreak,
11116
11637
  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", {
11638
+ })), (0, react.createElement)("li", { "data-testid": "recap-mastered" }, tr("settings.stats.mastered", {
11639
+ count: (data?.courses ?? []).reduce((n, c) => n + c.mastered, 0),
11640
+ due: data?.dueCount ?? 0
11641
+ })))), (0, react.createElement)(ProfileSection, {
11642
+ data,
11643
+ save: saveProfile,
11644
+ resolve: resolveProfileProposal,
11645
+ fire
11646
+ }), (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
11647
  href: `https://github.com/Kaiji-Z/dsh-plugin-lookatstudy/releases/tag/v${data.version}`,
11119
11648
  target: "_blank",
11120
11649
  rel: "noreferrer"